@idosgames/mcp 0.1.37 → 0.1.39

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 (43) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +2235 -27
  3. package/package.json +4 -1
  4. package/registry/host.json +3 -3
  5. package/registry/index.json +309 -156
  6. package/registry/modules/ai.json +7 -7
  7. package/registry/modules/board-game.json +14 -14
  8. package/registry/modules/boosts.json +3 -3
  9. package/registry/modules/character.json +32 -15
  10. package/registry/modules/chat.json +3 -3
  11. package/registry/modules/collections.json +7 -7
  12. package/registry/modules/coop-events.json +7 -7
  13. package/registry/modules/craft.json +3 -3
  14. package/registry/modules/deals.json +7 -7
  15. package/registry/modules/events.json +8 -8
  16. package/registry/modules/friends.json +9 -9
  17. package/registry/modules/hero-run.json +497 -0
  18. package/registry/modules/idle-rpg.json +70 -18
  19. package/registry/modules/inventory.json +3 -3
  20. package/registry/modules/invite.json +7 -7
  21. package/registry/modules/leaderboard.json +10 -10
  22. package/registry/modules/lootboxes.json +7 -7
  23. package/registry/modules/mailbox.json +8 -8
  24. package/registry/modules/marketplace.json +8 -8
  25. package/registry/modules/mini-game.json +8 -8
  26. package/registry/modules/multiplayer.json +7 -7
  27. package/registry/modules/premium.json +7 -7
  28. package/registry/modules/profile.json +7 -7
  29. package/registry/modules/pvp.json +7 -7
  30. package/registry/modules/quests.json +8 -8
  31. package/registry/modules/rewards.json +8 -8
  32. package/registry/modules/season.json +7 -7
  33. package/registry/modules/store.json +7 -7
  34. package/registry/modules/tutorials.json +7 -7
  35. package/registry/modules/video-studio.json +228 -0
  36. package/registry/modules/voxelcraft.json +42 -125
  37. package/registry/modules/workshop.json +3 -3
  38. package/registry/skills/hero-run.json +6 -0
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +1 -1
  42. package/registry/skills/video-studio.json +6 -0
  43. package/registry/video.json +9800 -0
@@ -31,15 +31,15 @@
31
31
  "name": "iDos Games",
32
32
  "url": "https://idosgames.com"
33
33
  },
34
- "version": "0.1.2"
34
+ "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.22.1",
38
- "@idosgames/module-sdk": "0.12.0",
39
- "@idosgames/react": "0.5.2",
37
+ "@idosgames/core": "0.23.0",
38
+ "@idosgames/module-sdk": "0.13.0",
39
+ "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "0df78530268b83685205589e187335a16c93661c44ce61f2388e86b1078d70c0",
42
+ "contentHash": "f30292cdadf0595a434aaae91598c279add865f03c5ae9767d5b4227e9d479ba",
43
43
  "files": [
44
44
  {
45
45
  "path": "i18n.ts",
@@ -55,15 +55,15 @@
55
55
  },
56
56
  {
57
57
  "path": "module.ts",
58
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeQuestsScreen, watchQuests } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: () => t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n Screen: makeQuestsScreen(ctx.features),\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled) watchQuests(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeQuestsScreen, watchQuests } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: () => t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n // a UScreen page: edge to edge, the whole height (the lobby gives it all of its area)\n fullBleed: true,\n Screen: makeQuestsScreen(ctx.features),\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled) watchQuests(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
59
59
  },
60
60
  {
61
61
  "path": "QuestsScreen.tsx",
62
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n EmptyState,\n Icon,\n U,\n UButton,\n UIconSlider,\n UItemFrame,\n UMilestonesPopup,\n UPopup,\n URow,\n USlider,\n UScreen,\n UTabs,\n UTimer,\n UTopBar,\n configSection,\n countdown,\n errorText,\n grantedBy,\n mergeLines,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n cycleResetAt,\n hasPremium,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type PremiumView,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests, the Layer Lab look of Board_Survival): the dark top bar with the\n// \"Complete N/M\" corner, the orange points track with its timer (tap → the milestone rewards window),\n// light list frames — the reward in a navy frame on the left, the name and a green slider, Claim on\n// the right, claimed quests dimmed — and the pill tabs of the cycles at the bottom. A quest opens the\n// Quest Detail popup (goals with their sliders + the reward).\n\nexport function makeQuestsScreen(features: FeatureRegistry) {\n return function QuestsScreen(_: FeatureScreenProps): ReactNode {\n // A locked VIP reward on the track opens the VIP system's offer, when the title sells one.\n const openVip = features.get(\"premium\")?.available\n ? (premiumID: string) => features.open(\"premium\", { premiumID })\n : undefined;\n return <Quests onOpenVip={openVip} />;\n };\n}\n\nconst ORDER = { Completed: 0, Active: 1, Expired: 2, Claimed: 3 } as const;\nconst INK_TEXT = \"#1f2a52\";\n\nfunction Quests({\n onOpenVip,\n}: {\n onOpenVip?: (premiumID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n const [claiming, setClaiming] = useState<string | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = cycleResetAt(questState, current);\n const ready = shown.filter((r) => r.status === \"Completed\");\n const finished = shown.filter((r) => r.status !== \"Active\").length;\n // Unity order: ready to claim first, then in progress, the claimed ones last.\n const sorted = [...shown].sort((a, b) => ORDER[a.status] - ORDER[b.status]);\n\n const claimOne = async (row: QuestRow) => {\n setClaiming(row.questID);\n const res = await client.quest.claimQuestReward(row.questID, row.cycleID);\n setClaiming(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void load();\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <UScreen\n footer={\n tabs.length > 1 ? (\n <UTabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n alert: rows.some(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ),\n }))}\n value={current}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <UTopBar\n title={t(\"title\")}\n cornerLabel={t(\"complete\")}\n corner={`${finished}/${shown.length}`}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n resetAt={cycleEnd}\n now={now}\n onClaimed={load}\n onOpenVip={onOpenVip}\n />\n ) : null}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 10,\n padding: \"12px 12px 16px\",\n }}\n >\n {ready.length >= 2 ? (\n <UButton\n tone=\"yellow\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}\n <span style={{ display: \"inline-flex\", gap: 2 }}>\n {claimableRewards(ready)\n .slice(0, 3)\n .map((l, i) => (\n <Icon key={i} glyph={catalog.iconOf(l)} size={20} />\n ))}\n </span>\n </UButton>\n ) : null}\n {sorted.map((row, i) => (\n <div\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n style={stagger(i)}\n onClick={() => setDetail(row)}\n >\n <QuestItem\n row={row}\n busy={claiming === row.questID}\n onClaim={() => void claimOne(row)}\n />\n </div>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </UScreen>\n );\n}\n\n/** One QuestItem row: the reward frame · the name + the slider · Claim / ✓ / percent. */\nfunction QuestItem({\n row,\n busy,\n onClaim,\n}: {\n row: QuestRow;\n busy: boolean;\n onClaim: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const first = row.reward[0] ?? null;\n return (\n <URow\n tone={row.status === \"Completed\" ? \"yellow\" : \"light\"}\n dim={row.status === \"Claimed\"}\n style={{ cursor: \"pointer\" }}\n >\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <UItemFrame line={first} size={60} />\n {row.reward.length > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: -6,\n top: -6,\n minWidth: 22,\n height: 22,\n borderRadius: 11,\n background: \"#ff243d\",\n border: `2px solid ${U.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(12),\n }}\n >\n +{row.reward.length - 1}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div\n style={{\n color: INK_TEXT,\n fontSize: 16,\n fontWeight: 900,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {catalog.localize(row.name)}\n </div>\n <USlider\n value={Math.min(row.progress, row.target)}\n max={row.target}\n />\n </div>\n <div onClick={(e) => e.stopPropagation()}>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={40} />\n ) : row.status === \"Completed\" ? (\n <UButton tone=\"green\" attract busy={busy} onClick={onClaim}>\n {t(\"claim\")}\n </UButton>\n ) : (\n <span\n style={{\n ...uText(15),\n display: \"inline-grid\",\n placeItems: \"center\",\n minWidth: 54,\n height: 34,\n borderRadius: 11,\n border: `3px solid ${U.ink}`,\n background: \"linear-gradient(180deg,#8d97b3,#5d6a92)\",\n }}\n >\n {Math.floor((row.progress / Math.max(1, row.target)) * 100)}%\n </span>\n )}\n </div>\n </div>\n </URow>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n const heading = (label: string) => (\n <div\n style={{\n ...uText(16),\n background: \"rgba(3,48,122,.55)\",\n borderRadius: 10,\n padding: \"4px 10px\",\n textAlign: \"center\",\n }}\n >\n {label}\n </div>\n );\n return (\n <UPopup tone=\"blue\" title={t(\"questDetail\")} onClose={onClose} width={420}>\n <div style={{ ...uText(20), textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.description ? (\n <div style={{ ...uText(14, \"#e3f2ff\"), textAlign: \"center\" }}>\n {catalog.localize(row.description)}\n </div>\n ) : null}\n {heading(t(\"objectives\"))}\n <div style={{ display: \"grid\", gap: 8 }}>\n {row.objectives.map((o) => (\n <URow key={o.id} tone={o.done ? \"yellow\" : \"light\"}>\n <div style={{ display: \"grid\", gap: 6 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n color: INK_TEXT,\n fontWeight: 900,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={22} /> : null}\n </div>\n <USlider value={Math.min(o.progress, o.target)} max={o.target} />\n </div>\n </URow>\n ))}\n </div>\n {heading(t(\"reward\"))}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {row.reward.map((l, i) => (\n <UItemFrame key={i} line={l} size={66} />\n ))}\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...uText(18, \"#8cf05a\"), textAlign: \"center\" }}>\n {t(\"done\")}\n </div>\n ) : null}\n </UPopup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref} style={{ display: \"grid\" }}>\n <UButton\n tone=\"green\"\n size=\"l\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </UButton>\n </div>\n );\n}\n\n/** Slider_Icon01 of the quests window: the cycle's points, its timer; tap → the rewards window. */\nfunction PointsTrack({\n cycleID,\n section,\n track,\n resetAt,\n now,\n onClaimed,\n onOpenVip,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n resetAt: string | null;\n now: number;\n onClaimed: () => Promise<void>;\n onOpenVip?: (premiumID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const premium = state?.Premium as PremiumView | undefined;\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n premium,\n );\n const vipID = milestones.find((m) => m.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 0, Date.now()) : false;\n const left = countdown(resetAt, now);\n if (milestones.length === 0)\n return left ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"10px 0 0\",\n }}\n >\n <UTimer>{left}</UTimer>\n </div>\n ) : null;\n // The slider shows the way to the NEXT milestone (Unity: \"13/50\"); past the last — the last one.\n const next =\n milestones.find((m) => m.required > points) ??\n milestones[milestones.length - 1];\n const target = next?.required ?? 1;\n const readyCount = milestones.filter((m) => m.state === \"ready\").length;\n\n return (\n <div\n style={{\n padding: \"14px 14px 8px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n background: \"rgba(2,32,92,.45)\",\n }}\n >\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph={readyCount > 0 ? \"gift\" : \"chest\"}\n value={Math.min(points, target)}\n max={target}\n onClick={() => setOpen(true)}\n />\n {readyCount > 0 ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: \"#ff243d\",\n border: `2px solid ${U.ink}`,\n animation: \"idos-u-attract 1.2s ease-in-out infinite\",\n }}\n />\n ) : null}\n </div>\n {left ? <UTimer>{left}</UTimer> : null}\n {open ? (\n <UMilestonesPopup\n glyph=\"star\"\n value={points}\n milestones={milestones.map((m) => ({\n id: m.id,\n required: m.required,\n free: m.reward,\n state: m.state,\n premium: m.vip,\n premiumState:\n m.vipState === \"granted\"\n ? \"claimed\"\n : m.vipState === \"ready\"\n ? \"ready\"\n : \"locked\",\n }))}\n premiumOwned={vipHeld}\n onPremiumLock={\n vipID && onOpenVip ? () => onOpenVip(vipID) : undefined\n }\n onClose={() => setOpen(false)}\n onClaim={async (id) => {\n const picked = milestones.find((m) => m.id === id);\n const res = await client.quest.claimMilestoneReward(cycleID, id);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : (picked?.reward ?? []));\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function watchQuests(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n ctx.every(60_000, () => void client.cache.ensureState([\"Quest\"]));\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
62
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n EmptyState,\n Columns,\n Icon,\n ProgressBar,\n Tabs,\n U,\n UButton,\n UIconSlider,\n UItemFrame,\n UMilestonesPopup,\n UPopup,\n URow,\n USlider,\n UScreen,\n UTimer,\n UTopBar,\n configSection,\n countdown,\n errorText,\n formatLineAmount,\n grantedBy,\n inked,\n mergeLines,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n cycleResetAt,\n hasPremium,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type PremiumView,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests in the casual sheet's \"Missions\" look: the top bar with the \"Complete N/M\" corner, the\n// cycles as segmented tabs under it (\"Daily | Challenge\"), the points track with its timer (tap → the\n// milestone rewards window), then full-width rows — a ready quest light blue with a green bar and the\n// green reward button, one in progress a deeper blue with an orange bar and the reward dimmed, a\n// claimed one faded. A quest opens the Quest Detail popup (goals with their bars + the reward).\n\nexport function makeQuestsScreen(features: FeatureRegistry) {\n return function QuestsScreen(_: FeatureScreenProps): ReactNode {\n // A locked VIP reward on the track opens the VIP system's offer, when the title sells one.\n const openVip = features.get(\"premium\")?.available\n ? (premiumID: string) => features.open(\"premium\", { premiumID })\n : undefined;\n return <Quests onOpenVip={openVip} />;\n };\n}\n\nconst ORDER = { Completed: 0, Active: 1, Expired: 2, Claimed: 3 } as const;\nconst INK_TEXT = \"#1f2a52\";\n\nfunction Quests({\n onOpenVip,\n}: {\n onOpenVip?: (premiumID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n const [claiming, setClaiming] = useState<string | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = cycleResetAt(questState, current);\n const ready = shown.filter((r) => r.status === \"Completed\");\n const finished = shown.filter((r) => r.status !== \"Active\").length;\n // Unity order: ready to claim first, then in progress, the claimed ones last.\n const sorted = [...shown].sort((a, b) => ORDER[a.status] - ORDER[b.status]);\n\n const claimOne = async (row: QuestRow) => {\n setClaiming(row.questID);\n const res = await client.quest.claimQuestReward(row.questID, row.cycleID);\n setClaiming(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void load();\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <UScreen>\n <UTopBar\n title={t(\"title\")}\n cornerLabel={t(\"complete\")}\n corner={`${finished}/${shown.length}`}\n />\n {tabs.length > 1 ? (\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n style={{\n borderRadius: 0,\n borderLeft: \"none\",\n borderRight: \"none\",\n borderTop: \"none\",\n }}\n />\n ) : null}\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n resetAt={cycleEnd}\n now={now}\n onClaimed={load}\n onOpenVip={onOpenVip}\n />\n ) : null}\n {ready.length >= 2 ? (\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 padding: \"10px 12px\",\n }}\n >\n <UButton\n tone=\"yellow\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}\n <span style={{ display: \"inline-flex\", gap: 2 }}>\n {claimableRewards(ready)\n .slice(0, 3)\n .map((l, i) => (\n <Icon key={i} glyph={catalog.iconOf(l)} size={20} />\n ))}\n </span>\n </UButton>\n </div>\n ) : null}\n {/* the rows: one column on a phone, side by side as cards on a tablet or a PC */}\n <Columns kind=\"strips\" style={{ flex: 1, paddingBottom: 16 }}>\n {sorted.map((row, i) => (\n <div\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n style={stagger(i)}\n onClick={() => setDetail(row)}\n >\n <QuestItem\n row={row}\n busy={claiming === row.questID}\n onClaim={() => void claimOne(row)}\n />\n </div>\n ))}\n </Columns>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </UScreen>\n );\n}\n\n/** One \"Missions\" row: the name over its bar on the left, the reward button on the right. */\nfunction QuestItem({\n row,\n busy,\n onClaim,\n}: {\n row: QuestRow;\n busy: boolean;\n onClaim: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const first = row.reward[0] ?? null;\n const ready = row.status === \"Completed\";\n const claimed = row.status === \"Claimed\";\n const reward = first ? (\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 8 }}>\n <Icon glyph={catalog.iconOf(first)} size={26} />\n <span style={{ fontSize: 20 }}>{formatLineAmount(first)}</span>\n {row.reward.length > 1 ? (\n <span style={{ fontSize: 13 }}>+{row.reward.length - 1}</span>\n ) : null}\n </span>\n ) : (\n t(\"claim\")\n );\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 14,\n // the edge-to-edge row keeps its text and button off the window's sides, more on a wide one\n padding: \"14px clamp(14px, 3vw, 28px) 16px\",\n cursor: \"pointer\",\n background: ready\n ? `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 70%, #fff) 0%, ${v.blue} 100%)`\n : `linear-gradient(180deg, color-mix(in srgb, ${v.panelDeep} 55%, ${v.bgTop}) 0%, color-mix(in srgb, ${v.panelDeep} 40%, ${v.bgTop}) 100%)`,\n borderBottom: `5px solid color-mix(in srgb, ${v.bgBottom} 70%, ${v.ink})`,\n opacity: claimed ? 0.55 : 1,\n }}\n >\n <div style={{ display: \"grid\", gap: 8, minWidth: 0 }}>\n <div\n style={{\n ...inked,\n fontSize: 17,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={Math.min(row.progress, row.target)}\n max={row.target}\n tone={ready || claimed ? \"green\" : \"orange\"}\n height={18}\n />\n </div>\n <div onClick={(e) => e.stopPropagation()}>\n {claimed ? (\n <Icon glyph=\"check\" size={46} />\n ) : (\n <Button\n tone={ready ? \"green\" : \"blue\"}\n attract={ready}\n busy={busy}\n disabled={!ready}\n onClick={onClaim}\n style={{\n minWidth: 120,\n minHeight: 58,\n ...(ready ? {} : { opacity: 0.55 }),\n }}\n >\n {reward}\n </Button>\n )}\n </div>\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n const heading = (label: string) => (\n <div\n style={{\n ...uText(16),\n background: `color-mix(in srgb, ${v.panelDeep} 65%, ${v.ink})`,\n borderRadius: 10,\n padding: \"4px 10px\",\n textAlign: \"center\",\n }}\n >\n {label}\n </div>\n );\n return (\n <UPopup tone=\"blue\" title={t(\"questDetail\")} onClose={onClose} width={420}>\n <div style={{ ...uText(20), textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.description ? (\n <div style={{ ...uText(14, \"#e3f2ff\"), textAlign: \"center\" }}>\n {catalog.localize(row.description)}\n </div>\n ) : null}\n {heading(t(\"objectives\"))}\n <div style={{ display: \"grid\", gap: 8 }}>\n {row.objectives.map((o) => (\n <URow key={o.id} tone={o.done ? \"yellow\" : \"light\"}>\n <div style={{ display: \"grid\", gap: 6 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n color: INK_TEXT,\n fontWeight: 900,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={22} /> : null}\n </div>\n <USlider value={Math.min(o.progress, o.target)} max={o.target} />\n </div>\n </URow>\n ))}\n </div>\n {heading(t(\"reward\"))}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {row.reward.map((l, i) => (\n <UItemFrame key={i} line={l} size={66} />\n ))}\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...uText(18, \"#8cf05a\"), textAlign: \"center\" }}>\n {t(\"done\")}\n </div>\n ) : null}\n </UPopup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref} style={{ display: \"grid\" }}>\n <UButton\n tone=\"green\"\n size=\"l\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </UButton>\n </div>\n );\n}\n\n/** Slider_Icon01 of the quests window: the cycle's points, its timer; tap → the rewards window. */\nfunction PointsTrack({\n cycleID,\n section,\n track,\n resetAt,\n now,\n onClaimed,\n onOpenVip,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n resetAt: string | null;\n now: number;\n onClaimed: () => Promise<void>;\n onOpenVip?: (premiumID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const premium = state?.Premium as PremiumView | undefined;\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n premium,\n );\n const vipID = milestones.find((m) => m.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 0, Date.now()) : false;\n const left = countdown(resetAt, now);\n if (milestones.length === 0)\n return left ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"10px 0 0\",\n }}\n >\n <UTimer>{left}</UTimer>\n </div>\n ) : null;\n // The slider shows the way to the NEXT milestone (Unity: \"13/50\"); past the last — the last one.\n const next =\n milestones.find((m) => m.required > points) ??\n milestones[milestones.length - 1];\n const target = next?.required ?? 1;\n const readyCount = milestones.filter((m) => m.state === \"ready\").length;\n\n return (\n <div\n style={{\n padding: \"14px 14px 8px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n background: `color-mix(in srgb, ${v.bgBottom} 45%, transparent)`,\n }}\n >\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph={readyCount > 0 ? \"gift\" : \"chest\"}\n value={Math.min(points, target)}\n max={target}\n onClick={() => setOpen(true)}\n />\n {readyCount > 0 ? (\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 {left ? <UTimer>{left}</UTimer> : null}\n {open ? (\n <UMilestonesPopup\n glyph=\"star\"\n value={points}\n milestones={milestones.map((m) => ({\n id: m.id,\n required: m.required,\n free: m.reward,\n state: m.state,\n premium: m.vip,\n premiumState:\n m.vipState === \"granted\"\n ? \"claimed\"\n : m.vipState === \"ready\"\n ? \"ready\"\n : \"locked\",\n }))}\n premiumOwned={vipHeld}\n onPremiumLock={\n vipID && onOpenVip ? () => onOpenVip(vipID) : undefined\n }\n onClose={() => setOpen(false)}\n onClaim={async (id) => {\n const picked = milestones.find((m) => m.id === id);\n const res = await client.quest.claimMilestoneReward(cycleID, id);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : (picked?.reward ?? []));\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function watchQuests(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n ctx.every(60_000, () => void client.cache.ensureState([\"Quest\"]));\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"quests\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Quests for any game: quests by cycle with details, claim all and the quest points track.\",\n \"description\": \"A ready quests screen (Unity Runtime/UI/Quests) for ANY game, built on the @idosgames/react/ui kit and client.quest. The title's quests: cycles as tabs (daily, weekly, achievements) with the timer to the next cycle, progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'quests' feature; its badge counts finished quests. Ships title-seed.json: four daily quests.\",\n \"provides\": [\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for finished quests\",\n \"demo daily quests\"\n ],\n \"tags\": [\n \"quests\",\n \"missions\",\n \"achievements\",\n \"daily\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
66
+ "content": "{\n \"id\": \"quests\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Quests for any game: quests by cycle with details, claim all and the quest points track.\",\n \"description\": \"A ready quests screen (Unity Runtime/UI/Quests) for ANY game, built on the @idosgames/react/ui kit and client.quest. The title's quests: cycles as tabs (daily, weekly, achievements) with the timer to the next cycle, progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'quests' feature; its badge counts finished quests. Ships title-seed.json: four daily quests.\",\n \"provides\": [\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for finished quests\",\n \"demo daily quests\"\n ],\n \"tags\": [\n \"quests\",\n \"missions\",\n \"achievements\",\n \"daily\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
67
67
  }
68
68
  ]
69
69
  }
@@ -28,15 +28,15 @@
28
28
  "name": "iDos Games",
29
29
  "url": "https://idosgames.com"
30
30
  },
31
- "version": "0.1.2"
31
+ "version": "0.1.3"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.22.1",
35
- "@idosgames/module-sdk": "0.12.0",
36
- "@idosgames/react": "0.5.2",
34
+ "@idosgames/core": "0.23.0",
35
+ "@idosgames/module-sdk": "0.13.0",
36
+ "@idosgames/react": "0.6.0",
37
37
  "react": "19.2.7"
38
38
  },
39
- "contentHash": "70fde2f88bc77f2c1006bea7e4dce11246951aad4166d8986c32c1acc4a80e4c",
39
+ "contentHash": "62f4f39dd5ef0dd3d72a43a5de98cbc1a4d3e1ccc161b08daf2a9f845e7f2652",
40
40
  "files": [
41
41
  {
42
42
  "path": "i18n.ts",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  {
50
50
  "path": "model.ts",
51
- "content": "// Pure helpers of the daily rewards screen: the calendar arithmetic. No React, no client calls —\n// covered by model.test.ts against the module's own demo settings.\n\nimport type {\n DailyCalendarDefinition,\n DailyRewardDay,\n RewardDefinitions,\n} from \"@idosgames/core\";\nimport type { Layout } from \"@idosgames/react/ui\";\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardDefinitions | undefined,\n): (DailyCalendarDefinition & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n // A calendar the server sends as null is skipped, not a crash.\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is NonNullable<typeof d> =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyRewardDay; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarDefinition,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n/** Whether the title has a daily calendar — until it does, the lobby hides the tab. */\nexport function hasTitleData(reward: RewardDefinitions | undefined): boolean {\n return Object.keys(reward?.DailyCalendars ?? {}).length > 0;\n}\n"
51
+ "content": "// Pure helpers of the daily rewards screen: the calendar arithmetic. No React, no client calls —\n// covered by model.test.ts against the module's own demo settings.\n\nimport type {\n DailyCalendarDefinition,\n DailyRewardDay,\n RewardDefinitions,\n} from \"@idosgames/core\";\nimport type { Layout } from \"@idosgames/react/ui\";\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardDefinitions | undefined,\n): (DailyCalendarDefinition & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n // A calendar the server sends as null is skipped, not a crash.\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is NonNullable<typeof d> =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyRewardDay; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarDefinition,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n // A week (owner, 2026-10-22): three a row on every width — 1-2-3, 4-5-6, and day 7 the whole\n // third row (lastCardSpan). A longer calendar keeps more columns, or it would be a long ribbon.\n if (days <= 7) return Math.min(3, Math.max(1, days));\n if (compact || layout !== \"tablet\") return 4;\n return 7;\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n/** Whether the title has a daily calendar — until it does, the lobby hides the tab. */\nexport function hasTitleData(reward: RewardDefinitions | undefined): boolean {\n return Object.keys(reward?.DailyCalendars ?? {}).length > 0;\n}\n"
52
52
  },
53
53
  {
54
54
  "path": "module.ts",
@@ -56,11 +56,11 @@
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 outlined,\n panel,\n Popup,\n SectionTitle,\n tokenGlyph,\n toneSurface,\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 (Unity Alikhan/Dailies): the login calendar — collected / today / locked cards, the\n// last one a big milestone. Quests are a system of their own (the quests module).\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 <div>\n {compact ? null : (\n <SectionTitle>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </SectionTitle>\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 return (\n <div\n key={day.DayNumber ?? i}\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n borderRadius: 14,\n padding: \"8px 4px\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n position: \"relative\",\n ...(state === \"collected\"\n ? toneSurface(\"green\")\n : today\n ? toneSurface(\"gold\")\n : day.IsMilestone\n ? toneSurface(\"blue\")\n : { background: v.wellSoft }),\n border: today ? `2px solid ${v.text}` : `2px solid ${v.line}`,\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 12 }}>\n {t(\"day\")} {day.DayNumber ?? i + 1}\n </span>\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 ? 36 : 30}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...outlined, fontSize: 12 }}>\n ×{main.amount}\n </span>\n ) : null}\n {state === \"collected\" ? (\n <span style={{ position: \"absolute\", top: -6, right: -6 }}>\n <Icon glyph=\"check\" size={22} />\n </span>\n ) : null}\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 // 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"
60
60
  },
61
61
  {
62
62
  "path": "module.meta.json",
63
- "content": "{\n \"id\": \"rewards\",\n \"type\": \"feature\",\n \"summary\": \"Daily rewards for any game: a login calendar that greets the player once per session.\",\n \"description\": \"A ready daily rewards screen for ANY game, built on the @idosgames/react/ui kit and client.reward. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project). Registers the 'rewards' feature; its badge shows the daily bonus waiting. Quests are the quests system. Ships title-seed.json: a 7-day calendar.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"badge for the waiting daily bonus\",\n \"demo daily calendar\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"login\",\n \"retention\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
63
+ "content": "{\n \"id\": \"rewards\",\n \"type\": \"feature\",\n \"summary\": \"Daily rewards for any game: a login calendar that greets the player once per session.\",\n \"description\": \"A ready daily rewards screen for ANY game, built on the @idosgames/react/ui kit and client.reward. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project). Registers the 'rewards' feature; its badge shows the daily bonus waiting. Quests are the quests system. Ships title-seed.json: a 7-day calendar.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"badge for the waiting daily bonus\",\n \"demo daily calendar\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"login\",\n \"retention\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
64
64
  }
65
65
  ]
66
66
  }
@@ -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.22.1",
40
- "@idosgames/module-sdk": "0.12.0",
41
- "@idosgames/react": "0.5.2",
39
+ "@idosgames/core": "0.23.0",
40
+ "@idosgames/module-sdk": "0.13.0",
41
+ "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "25ac34032e14fd679c08100e3d8c513f13f3a85b3b021f20f628e627a11b2c8a",
44
+ "contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -61,11 +61,11 @@
61
61
  },
62
62
  {
63
63
  "path": "SeasonScreen.tsx",
64
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveSeasonInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n type PremiumView,\n type TierRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Seasons (Unity Core/Window MilestoneRewards): the active season of every chain — its timer, the\n// status bar toward the next tier and the tier ladder. Each tier pays once; a VIP gets a second\n// prize on the tiers claimed while the pass is active. The VIP pass is a popup.\n\ntype SeasonEntry = { info: ActiveSeasonInfo };\n\n/** Active seasons of every chain, shared by the screen and the badge watcher. */\nexport function createSeasonStore(features: FeatureRegistry) {\n let seasons: SeasonEntry[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"season\",\n seasons\n ? seasonBadge(\n seasons.map((s) => s.info),\n premium(),\n Date.now(),\n )\n : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it (the\n // screen, the watcher and the reload after a claim overlap — see the events store).\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const defs = await client.season.getDefinitions();\n if (!defs.ok) {\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n seasons ??= [];\n } else {\n const chains = Object.keys(defs.data.Chains ?? {});\n const active = await Promise.all(\n chains.map((c) => client.season.getActiveSeason(c)),\n );\n seasons = active.flatMap((r) => (r.ok ? [{ info: r.data }] : []));\n }\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => seasons,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type SeasonStore = ReturnType<typeof createSeasonStore>;\n\nexport function makeSeasonScreen(store: SeasonStore) {\n return function SeasonScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const seasons = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!seasons)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (seasons.length === 0)\n return <EmptyState glyph=\"trophy\" text={t(\"noSeason\")} />;\n const current =\n seasons.find((s) => s.info.SeasonChainID === tab) ?? seasons[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={seasons.map((s) => ({\n id: s.info.SeasonChainID,\n label: catalog.localize(\n s.info.Season?.DisplayName ?? s.info.SeasonChainID,\n ),\n }))}\n value={current.info.SeasonChainID}\n onChange={setTab}\n />\n <SeasonView\n key={current.info.SeasonChainID}\n info={current.info}\n store={store}\n />\n </div>\n );\n };\n}\n\nexport function watchSeason(ctx: ModuleContext, store: SeasonStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n // Season points also arrive from other systems (a board roll, a quest) as event tokens: re-read\n // soon after, a tier may have been reached.\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"season:statusTokensGranted\", () => void store.load(client)),\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no season running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction SeasonView({\n info,\n store,\n}: {\n info: ActiveSeasonInfo;\n store: SeasonStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = tierRows(info, premium, now);\n const cached = (\n state?.EventToken?.Season as\n Record<string, { Balance?: { TotalEarned?: number } }> | undefined\n )?.[info.SeasonChainID]?.Balance?.TotalEarned;\n const tokens = seasonTokens(info, cached);\n const bar = tierProgress(rows, tokens);\n const ready = readyTiers(rows);\n const vipID = rows.find((r) => r.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = rows.some((r) => r.vipHeld);\n const currentRow = rows.find((r) => r.current);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.season.claimTierRewardsBatch(\n info.SeasonChainID,\n ready,\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : rows\n .filter((r) => r.state === \"ready\")\n .flatMap((r) => [...r.reward, ...(r.vipHeld ? r.vip : [])]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"blue\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph={info.Season?.AssetPaths?.icon ?? \"trophy\"} size={46} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(info.Season?.DisplayName ?? info.SeasonChainID)}\n </div>\n <Timer at={info.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {currentRow ? (\n <div style={{ textAlign: \"right\", ...outlined }}>\n <div style={{ fontSize: 11, color: v.textDim }}>{t(\"tier\")}</div>\n <div style={{ fontSize: 20 }}>\n {currentRow.name\n ? catalog.localize(currentRow.name)\n : currentRow.tier}\n </div>\n </div>\n ) : null}\n </div>\n {info.Season?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(info.Season.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"status\")}: {formatAmount(tokens)}\n </span>\n <span>\n {bar.next\n ? `${t(\"nextTier\")} ${formatAmount(bar.goal)}`\n : t(\"maxTier\")}\n </span>\n </div>\n <ProgressBar\n value={bar.next ? tokens : 1}\n max={bar.next ? Math.max(1, bar.goal) : 1}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"season:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <TierCard\n key={row.tier}\n info={info}\n row={row}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n </div>\n );\n}\n\nfunction TierCard({\n info,\n row,\n store,\n style,\n}: {\n info: ActiveSeasonInfo;\n row: TierRow;\n store: SeasonStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.season.claimTierReward(\n info.SeasonChainID,\n row.tier,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0 ? got : [...row.reward, ...(row.vipHeld ? row.vip : [])],\n );\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border:\n row.state === \"ready\"\n ? `2px solid ${v.gold}`\n : row.current\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 56 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"trophy\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.name ? catalog.localize(row.name) : `${t(\"tier\")} ${row.tier}`}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.current ? (\n <div style={{ ...outlined, fontSize: 11, color: v.blue }}>\n {t(\"current\")}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipHeld && row.state !== \"claimed\" ? 1 : 0.5,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n </div>\n ) : null}\n </div>\n <div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`season:claim:${row.tier}`}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n"
64
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveSeasonInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n ProgressBar,\n LevelRoadRow,\n formatLineAmount,\n type RoadState,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n type PremiumView,\n type TierRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Seasons (Unity Core/Window MilestoneRewards): the active season of every chain — its timer, the\n// status bar toward the next tier and the tier ladder. Each tier pays once; a VIP gets a second\n// prize on the tiers claimed while the pass is active. The VIP pass is a popup.\n\ntype SeasonEntry = { info: ActiveSeasonInfo };\n\n/** Active seasons of every chain, shared by the screen and the badge watcher. */\nexport function createSeasonStore(features: FeatureRegistry) {\n let seasons: SeasonEntry[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"season\",\n seasons\n ? seasonBadge(\n seasons.map((s) => s.info),\n premium(),\n Date.now(),\n )\n : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it (the\n // screen, the watcher and the reload after a claim overlap — see the events store).\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const defs = await client.season.getDefinitions();\n if (!defs.ok) {\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n seasons ??= [];\n } else {\n const chains = Object.keys(defs.data.Chains ?? {});\n const active = await Promise.all(\n chains.map((c) => client.season.getActiveSeason(c)),\n );\n seasons = active.flatMap((r) => (r.ok ? [{ info: r.data }] : []));\n }\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => seasons,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type SeasonStore = ReturnType<typeof createSeasonStore>;\n\nexport function makeSeasonScreen(store: SeasonStore) {\n return function SeasonScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const seasons = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!seasons)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (seasons.length === 0)\n return <EmptyState glyph=\"trophy\" text={t(\"noSeason\")} />;\n const current =\n seasons.find((s) => s.info.SeasonChainID === tab) ?? seasons[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={seasons.map((s) => ({\n id: s.info.SeasonChainID,\n label: catalog.localize(\n s.info.Season?.DisplayName ?? s.info.SeasonChainID,\n ),\n }))}\n value={current.info.SeasonChainID}\n onChange={setTab}\n />\n <SeasonView\n key={current.info.SeasonChainID}\n info={current.info}\n store={store}\n />\n </div>\n );\n };\n}\n\nexport function watchSeason(ctx: ModuleContext, store: SeasonStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n // Season points also arrive from other systems (a board roll, a quest) as event tokens: re-read\n // soon after, a tier may have been reached.\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"season:statusTokensGranted\", () => void store.load(client)),\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no season running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n });\n}\n\nfunction SeasonView({\n info,\n store,\n}: {\n info: ActiveSeasonInfo;\n store: SeasonStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = tierRows(info, premium, now);\n const cached = (\n state?.EventToken?.Season as\n Record<string, { Balance?: { TotalEarned?: number } }> | undefined\n )?.[info.SeasonChainID]?.Balance?.TotalEarned;\n const tokens = seasonTokens(info, cached);\n const bar = tierProgress(rows, tokens);\n const ready = readyTiers(rows);\n const vipID = rows.find((r) => r.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = rows.some((r) => r.vipHeld);\n const currentRow = rows.find((r) => r.current);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.season.claimTierRewardsBatch(\n info.SeasonChainID,\n ready,\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : rows\n .filter((r) => r.state === \"ready\")\n .flatMap((r) => [...r.reward, ...(r.vipHeld ? r.vip : [])]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"blue\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph={info.Season?.AssetPaths?.icon ?? \"trophy\"} size={46} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(info.Season?.DisplayName ?? info.SeasonChainID)}\n </div>\n <Timer at={info.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {currentRow ? (\n <div style={{ textAlign: \"right\", ...outlined }}>\n <div style={{ fontSize: 11, color: v.textDim }}>{t(\"tier\")}</div>\n <div style={{ fontSize: 20 }}>\n {currentRow.name\n ? catalog.localize(currentRow.name)\n : currentRow.tier}\n </div>\n </div>\n ) : null}\n </div>\n {info.Season?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(info.Season.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"status\")}: {formatAmount(tokens)}\n </span>\n <span>\n {bar.next\n ? `${t(\"nextTier\")} ${formatAmount(bar.goal)}`\n : t(\"maxTier\")}\n </span>\n </div>\n <ProgressBar\n value={bar.next ? tokens : 1}\n max={bar.next ? Math.max(1, bar.goal) : 1}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...toneSurface(\"purple\"),\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"season:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <Columns kind=\"ranked\" gap={8}>\n {rows.map((row, i) => (\n <TierCard\n key={row.tier}\n info={info}\n row={row}\n store={store}\n style={stagger(i)}\n />\n ))}\n </Columns>\n </div>\n );\n}\n\nfunction TierCard({\n info,\n row,\n store,\n style,\n}: {\n info: ActiveSeasonInfo;\n row: TierRow;\n store: SeasonStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.season.claimTierReward(\n info.SeasonChainID,\n row.tier,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0 ? got : [...row.reward, ...(row.vipHeld ? row.vip : [])],\n );\n await store.load(client);\n };\n\n // A step of the sheet's level road: claimed — faded blue with a tick, ready — gold and sparkling\n // (a tap claims it), the player's current tier — white, the rest ahead — dim.\n const main = row.reward[0];\n const state: RoadState =\n row.state === \"claimed\"\n ? \"done\"\n : row.state === \"ready\"\n ? \"current\"\n : row.current\n ? \"next\"\n : \"locked\";\n return (\n <div\n ref={ref}\n data-tutorial-anchor={\n row.state === \"ready\" ? `season:claim:${row.tier}` : undefined\n }\n style={{ display: \"grid\", gap: 4, opacity: busy ? 0.7 : 1, ...style }}\n >\n <LevelRoadRow\n level={row.tier}\n label={row.name ? catalog.localize(row.name) : t(\"tier\")}\n state={state}\n tag={\n row.state === \"ready\"\n ? t(\"claim\")\n : row.current\n ? t(\"current\")\n : undefined\n }\n reward={\n main ? <Icon glyph={catalog.iconOf(main)} size={46} /> : undefined\n }\n rewardLabel={main ? formatLineAmount(main) : undefined}\n onClick={\n row.state === \"ready\" && !busy ? () => void claim() : undefined\n }\n />\n {row.reward.length > 1 || row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 10,\n padding: \"0 8px\",\n }}\n >\n {row.reward.length > 1 ? (\n <ResourceList lines={row.reward.slice(1)} size={20} />\n ) : null}\n {row.vip.length > 0 ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipHeld && row.state !== \"claimed\" ? 1 : 0.5,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n </span>\n ) : null}\n </div>\n ) : null}\n </div>\n );\n}\n"
65
65
  },
66
66
  {
67
67
  "path": "module.meta.json",
68
- "content": "{\n \"id\": \"season\",\n \"type\": \"feature\",\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"hand-off to the VIP system\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
68
+ "content": "{\n \"id\": \"season\",\n \"type\": \"feature\",\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"hand-off to the VIP system\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
69
69
  }
70
70
  ]
71
71
  }
@@ -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.22.1",
40
- "@idosgames/module-sdk": "0.12.0",
41
- "@idosgames/react": "0.5.2",
39
+ "@idosgames/core": "0.23.0",
40
+ "@idosgames/module-sdk": "0.13.0",
41
+ "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "8442dbfd3b218dc5b93e890604811b5fd9df2893ae492a52a0a37b035ed804d1",
44
+ "contentHash": "9d642c2b6780919c897c46931b81f42441e38240abf6c723522da6369c4a6ba6",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -61,11 +61,11 @@
61
61
  },
62
62
  {
63
63
  "path": "StoreScreen.tsx",
64
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n canAfford,\n costLines,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n earliestRefresh,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n ...panel,\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n background:\n free && !disabled\n ? `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 45%, ${v.panelDeep}) 0%, ${v.panelDeep} 100%)`\n : panel.background,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone={free ? \"green\" : \"gold\"}\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\" }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
64
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n costLines,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n earliestRefresh,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45), 0 4px 0 ${v.shadow}`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
65
65
  },
66
66
  {
67
67
  "path": "module.meta.json",
68
- "content": "{\n \"id\": \"store\",\n \"type\": \"feature\",\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
68
+ "content": "{\n \"id\": \"store\",\n \"type\": \"feature\",\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
69
69
  }
70
70
  ]
71
71
  }