@idosgames/mcp 0.1.35 → 0.1.37

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 (38) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +5 -5
  4. package/registry/index.json +124 -120
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +37 -37
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +15 -15
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +8 -8
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +3 -3
  19. package/registry/modules/leaderboard.json +12 -12
  20. package/registry/modules/lootboxes.json +8 -8
  21. package/registry/modules/mailbox.json +3 -3
  22. package/registry/modules/marketplace.json +3 -3
  23. package/registry/modules/mini-game.json +7 -7
  24. package/registry/modules/multiplayer.json +28 -16
  25. package/registry/modules/premium.json +3 -3
  26. package/registry/modules/profile.json +3 -3
  27. package/registry/modules/pvp.json +3 -3
  28. package/registry/modules/quests.json +10 -10
  29. package/registry/modules/rewards.json +3 -3
  30. package/registry/modules/season.json +3 -3
  31. package/registry/modules/store.json +3 -3
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +13 -13
  34. package/registry/modules/workshop.json +3 -3
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/idosgames-module-contract.json +1 -1
  37. package/registry/skills/leaderboard-system.json +2 -2
  38. package/registry/skills/multiplayer-realtime.json +1 -1
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.6",
39
- "@idosgames/module-sdk": "0.11.0",
40
- "@idosgames/react": "0.5.1",
38
+ "@idosgames/core": "0.22.1",
39
+ "@idosgames/module-sdk": "0.12.0",
40
+ "@idosgames/react": "0.5.2",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "8ee2540d83607f36c770ed998f2a3806edac1c0373b89a3425549f3f80f0e9ff",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.2"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.6",
38
- "@idosgames/module-sdk": "0.11.0",
39
- "@idosgames/react": "0.5.1",
37
+ "@idosgames/core": "0.22.1",
38
+ "@idosgames/module-sdk": "0.12.0",
39
+ "@idosgames/react": "0.5.2",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "bb88c07207fcc242fa2328e5aece9f7afca487cf8a6e0947de8576f387377a34",
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.2"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.6",
36
- "@idosgames/module-sdk": "0.11.0",
37
- "@idosgames/react": "0.5.1",
35
+ "@idosgames/core": "0.22.1",
36
+ "@idosgames/module-sdk": "0.12.0",
37
+ "@idosgames/react": "0.5.2",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "ed7bff8cefd18dfb597b5a09e58c2ee5fc8500dc41e37091fa1e798540307db6",
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.6",
39
- "@idosgames/module-sdk": "0.11.0",
40
- "@idosgames/react": "0.5.1",
38
+ "@idosgames/core": "0.22.1",
39
+ "@idosgames/module-sdk": "0.12.0",
40
+ "@idosgames/react": "0.5.2",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "f4d962e257dc5647a013afc7899b43134964f6505431f9ed1147611c67c4af90",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.2"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.6",
38
- "@idosgames/module-sdk": "0.11.0",
39
- "@idosgames/react": "0.5.1",
37
+ "@idosgames/core": "0.22.1",
38
+ "@idosgames/module-sdk": "0.12.0",
39
+ "@idosgames/react": "0.5.2",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "4f1608aa1fd90cf3561d8da43160187b46cc15b0f2f22fd0d980142ef90f0ef6",
@@ -34,23 +34,23 @@
34
34
  "name": "iDos Games",
35
35
  "url": "https://idosgames.com"
36
36
  },
37
- "version": "0.1.2"
37
+ "version": "0.1.3"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.21.6",
41
- "@idosgames/module-sdk": "0.11.0",
42
- "@idosgames/react": "0.5.1",
40
+ "@idosgames/core": "0.22.1",
41
+ "@idosgames/module-sdk": "0.12.0",
42
+ "@idosgames/react": "0.5.2",
43
43
  "react": "19.2.7"
44
44
  },
45
- "contentHash": "c3172782893c86d170bbd96c9a2dfbf9bab9ab5e2780c67d72f598ce8bc43753",
45
+ "contentHash": "5cdfd90f4bb56df44d95dea46bb8bd49e682577c73b18c739ff9b5d0f7c758dc",
46
46
  "files": [
47
47
  {
48
48
  "path": "EventsScreen.tsx",
49
- "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 { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\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 claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n // 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 used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\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 EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event 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 EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"red\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (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=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
49
+ "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 { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Center,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Spinner,\n U,\n UButton,\n UItemFrame,\n UPopup,\n URays,\n UScreen,\n UTabs,\n UTimer,\n U_PATTERN,\n tokenGlyph,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n // 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 used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\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 EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <UScreen\n background=\"#00adff\"\n footer={\n events.length > 1 ? (\n <UTabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(\n e.Content?.DisplayName ?? e.TimedEventID,\n ),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <EventView key={current.TimedEventID} event={current} store={store} />\n </UScreen>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event 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 EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n const hasVip = rows.some((r) => r.vip.length > 0);\n const level = rows.filter((r) => goal.earned >= r.required).length;\n const left = countdown(event.ComputedEndUtc, now);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\" }}>\n {/* ── TopBg: the pass hero — curtains, gold piles, the chest over its light ── */}\n <div\n style={{\n position: \"relative\",\n height: 178,\n overflow: \"hidden\",\n background:\n \"radial-gradient(ellipse at 50% 30%, #6a5cff 0%, #1a9eff 75%)\",\n }}\n >\n <EventHeroArt />\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: 8,\n marginLeft: -80,\n width: 160,\n height: 160,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -20 }}>\n <URays size={200} color=\"rgba(255,255,255,.35)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.6s ease-in-out infinite\",\n filter: \"drop-shadow(0 6px 0 rgba(0,0,0,.3))\",\n }}\n >\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"chest\"}\n size={112}\n fallback=\"chest\"\n />\n </span>\n </div>\n </div>\n\n {/* ── PanelFrame_TopBar03: the title banner, timer, level slider, Activate ── */}\n <div\n style={{\n position: \"relative\",\n background: \"linear-gradient(180deg, #3d3d58 0%, #35354f 100%)\",\n borderTop: `6px solid ${U.gold}`,\n borderBottom: `3px solid ${U.ink}`,\n padding: \"30px 12px 12px\",\n display: \"grid\",\n gap: 10,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: \"50%\",\n top: -26,\n transform: \"translateX(-50%)\",\n maxWidth: \"86%\",\n padding: \"6px 28px 8px\",\n background: `linear-gradient(180deg, #ffe066 0%, ${U.gold} 100%)`,\n border: `3px solid ${U.ink}`,\n borderRadius: \"14px 14px 10px 10px\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 4px 0 rgba(0,0,0,.3)\",\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n ...uText(20),\n }}\n >\n {catalog.localize(event.Content?.DisplayName ?? event.TimedEventID)}\n </div>\n {left ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UTimer>{left}</UTimer>\n </div>\n ) : null}\n <LevelSlider\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n level={level}\n glyph={tokenGlyph({ kind: \"token\", id: \"TimedEvent\", amount: 0 })}\n />\n {vipID && !vipHeld && store.vipOnSale() ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <UButton tone=\"yellow\" attract onClick={() => store.openVip(vipID)}>\n <Icon glyph=\"lock\" size={18} /> {t(\"activate\")}\n </UButton>\n </div>\n ) : null}\n <span style={{ position: \"absolute\", right: 10, top: 12 }}>\n <UButton\n tone=\"sky\"\n size=\"s\"\n onClick={() => setSources(true)}\n aria-label={t(\"howToEarn\")}\n >\n ?\n </UButton>\n </span>\n {event.Content?.Description ? (\n <div style={{ ...uText(13, \"#d6dcff\"), textAlign: \"center\" }}>\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <UButton\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </UButton>\n ) : null}\n </div>\n\n {/* ── Bottom: Free (blue) | the track | Premium (yellow) ── */}\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n gridTemplateColumns: hasVip ? \"1fr 44px 1fr\" : \"1fr 44px\",\n background: hasVip\n ? `${U_PATTERN}, linear-gradient(90deg, #0aabf5 50%, #fad100 50%)`\n : `${U_PATTERN}, #0aabf5`,\n paddingBottom: 14,\n }}\n >\n <ColumnHead tone=\"free\" />\n <span />\n {hasVip ? <ColumnHead tone=\"premium\" held={vipHeld} /> : null}\n {/* the vertical stage track down the middle */}\n <div\n style={{\n position: \"absolute\",\n left: hasVip ? \"50%\" : \"calc(100% - 22px)\",\n top: 62,\n bottom: 24,\n width: 14,\n marginLeft: -7,\n borderRadius: 7,\n background: \"#232639\",\n border: `2px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n height: `${rows.length > 0 ? (level / rows.length) * 100 : 0}%`,\n background: \"linear-gradient(90deg, #ffd06b, #ff9a1a)\",\n transition: \"height .5s ease\",\n }}\n />\n </div>\n {rows.map((row, i) => (\n <MilestoneRowView\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n hasVip={hasVip}\n vipID={vipID}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nconst COIN_SPOTS: Array<[number, number]> = [\n [60, 158],\n [86, 150],\n [112, 156],\n [134, 162],\n [268, 156],\n [294, 148],\n [320, 152],\n [346, 160],\n];\n\n/** Background_Pass curtains + gold piles behind the chest, drawn in SVG. */\nfunction EventHeroArt(): ReactNode {\n return (\n <svg\n viewBox=\"0 0 400 178\"\n preserveAspectRatio=\"xMidYMid slice\"\n style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n aria-hidden\n >\n <g stroke={U.ink} strokeWidth=\"3\" strokeLinejoin=\"round\">\n <path d=\"M0 0 L120 0 C100 40 70 60 52 178 L0 178 Z\" fill=\"#c2185b\" />\n <path d=\"M0 0 L84 0 C70 50 40 80 26 178 L0 178 Z\" fill=\"#e91e63\" />\n <path\n d=\"M400 0 L280 0 C300 40 330 60 348 178 L400 178 Z\"\n fill=\"#c2185b\"\n />\n <path\n d=\"M400 0 L316 0 C330 50 360 80 374 178 L400 178 Z\"\n fill=\"#e91e63\"\n />\n <path\n d=\"M-10 0 L410 0 L410 16 C340 30 270 22 200 30 C130 22 60 30 -10 16 Z\"\n fill=\"#ffce0d\"\n />\n <ellipse cx=\"96\" cy=\"170\" rx=\"70\" ry=\"22\" fill=\"#f5a90c\" />\n <ellipse cx=\"304\" cy=\"168\" rx=\"80\" ry=\"24\" fill=\"#f5a90c\" />\n </g>\n <g fill=\"#ffe066\" stroke=\"#a8650a\" strokeWidth=\"2\">\n {COIN_SPOTS.map(([x, y], i) => (\n <ellipse key={i} cx={x} cy={y} rx=\"14\" ry=\"6\" />\n ))}\n </g>\n </svg>\n );\n}\n\n/** Slider_Icon02: the token icon at the left end, the orange fill, the level badge at the right. */\nfunction LevelSlider({\n value,\n max,\n level,\n glyph,\n}: {\n value: number;\n max: number;\n level: number;\n glyph: string;\n}): ReactNode {\n const pct = Math.max(0, Math.min(1, value / max));\n return (\n <div\n style={{\n position: \"relative\",\n height: 52,\n display: \"flex\",\n alignItems: \"center\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n left: 26,\n right: 26,\n height: 28,\n borderRadius: 14,\n background: \"#1f1f29\",\n border: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${pct * 100}%`,\n height: \"100%\",\n background: \"linear-gradient(180deg, #ffd06b 0%, #ff9a1a 100%)\",\n boxShadow: \"inset 0 3px 0 rgba(255,255,255,.35)\",\n transition: \"width .4s ease\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(14),\n }}\n >\n {formatAmount(value)} / {formatAmount(max)}\n </div>\n </div>\n <span\n style={{\n position: \"absolute\",\n left: 0,\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph={glyph} size={50} fallback=\"star\" />\n </span>\n <span\n style={{\n position: \"absolute\",\n right: 0,\n width: 48,\n height: 48,\n borderRadius: \"50%\",\n background:\n \"radial-gradient(circle at 50% 35%, #5fe4ff 0%, #00a6f0 70%)\",\n border: `3px solid ${U.ink}`,\n boxShadow: \"inset 0 0 0 4px #33d1ff\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(20),\n }}\n >\n {level}\n </span>\n </div>\n );\n}\n\n/** Icon_Ticket_Silver / _Gold with FREE / PREMIUM over each column. */\nfunction ColumnHead({\n tone,\n held,\n}: {\n tone: \"free\" | \"premium\";\n held?: boolean;\n}): ReactNode {\n const free = tone === \"free\";\n return (\n <div\n style={{\n display: \"grid\",\n placeItems: \"center\",\n padding: \"12px 6px 10px\",\n }}\n >\n <span\n style={{\n padding: \"6px 22px\",\n borderRadius: 10,\n border: `3px solid ${U.ink}`,\n background: free\n ? \"linear-gradient(180deg, #f2f6fb 0%, #a9b6cc 100%)\"\n : \"linear-gradient(180deg, #ffe066 0%, #f5a90c 100%)\",\n boxShadow:\n \"inset 0 3px 0 rgba(255,255,255,.5), 0 3px 0 rgba(0,0,0,.3)\",\n ...uText(18),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n {free ? null : <Icon glyph={held ? \"crown\" : \"lock\"} size={18} />}\n {free ? t(\"free\") : t(\"premium\")}\n </span>\n </div>\n );\n}\n\nconst BUBBLE = {\n free: {\n shadow: \"#6370c4\",\n bg: \"#ccdbf5\",\n border: \"#8fa1e8\",\n inner: \"#adc2ff\",\n },\n premium: {\n shadow: \"#d46300\",\n bg: \"#fff542\",\n border: \"#fca621\",\n inner: \"#ffd612\",\n },\n} as const;\n\n/** BubbleFrame05 (FrameFree / FramePremium): a speech bubble pointing at the track, the reward in it. */\nfunction Bubble({\n tone,\n lines,\n dim,\n done,\n locked,\n action,\n onLock,\n}: {\n tone: \"free\" | \"premium\";\n lines: ResourceLine[];\n dim: boolean;\n done: boolean;\n locked?: boolean;\n action?: ReactNode;\n onLock?: () => void;\n}): ReactNode {\n const c = BUBBLE[tone];\n const toRight = tone === \"free\";\n if (lines.length === 0) return <div />;\n return (\n <div\n style={{\n position: \"relative\",\n padding: toRight ? \"6px 12px 24px 8px\" : \"6px 8px 24px 12px\",\n }}\n >\n <div\n style={{\n position: \"relative\",\n borderRadius: 16,\n background: c.bg,\n border: `3px solid ${U.ink}`,\n boxShadow: `0 6px 0 ${c.shadow}`,\n padding: 7,\n minHeight: 88,\n }}\n >\n {/* the pointer toward the track */}\n <span\n style={{\n position: \"absolute\",\n top: 30,\n ...(toRight ? { right: -14 } : { left: -14 }),\n width: 14,\n height: 22,\n background: U.ink,\n clipPath: toRight\n ? \"polygon(0 0, 100% 50%, 0 100%)\"\n : \"polygon(100% 0, 0 50%, 100% 100%)\",\n }}\n />\n <div\n style={{\n borderRadius: 11,\n background: c.inner,\n border: `3px solid ${c.border}`,\n minHeight: 68,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n flexWrap: \"wrap\",\n padding: 4,\n }}\n >\n {lines.slice(0, 2).map((l, i) => (\n <UItemFrame key={i} line={l} size={lines.length > 1 ? 48 : 58} />\n ))}\n </div>\n {dim ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 13,\n background: \"rgba(20,18,26,.45)\",\n }}\n />\n ) : null}\n {done ? (\n <span style={{ position: \"absolute\", right: -8, top: -10 }}>\n <Icon glyph=\"check\" size={34} />\n </span>\n ) : null}\n {locked ? (\n <button\n type=\"button\"\n onClick={onLock}\n aria-label={t(\"getVip\")}\n style={{\n position: \"absolute\",\n right: -10,\n top: -14,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: onLock ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"lock\" size={36} />\n </button>\n ) : null}\n </div>\n {action ? (\n <div\n style={{\n position: \"absolute\",\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n {action}\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneRowView({\n event,\n row,\n index,\n earned,\n store,\n hasVip,\n vipID,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n hasVip: boolean;\n vipID: string | null;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\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.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n const claimBtn = (label: string) => (\n <UButton\n tone=\"green\"\n size=\"s\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {label}\n </UButton>\n );\n return (\n <>\n <div style={style} data-index={index}>\n <Bubble\n tone=\"free\"\n lines={row.reward}\n dim={!reached}\n done={row.state === \"claimed\"}\n action={row.state === \"ready\" ? claimBtn(t(\"claim\")) : undefined}\n />\n </div>\n {/* the stage badge on the track */}\n <div\n ref={ref}\n style={{\n position: \"relative\",\n zIndex: 1,\n display: \"grid\",\n justifyItems: \"center\",\n paddingTop: 34,\n }}\n >\n <span\n style={{\n minWidth: 38,\n height: 38,\n padding: \"0 4px\",\n borderRadius: 19,\n border: `3px solid ${U.ink}`,\n background: reached\n ? \"linear-gradient(180deg, #ffe066, #f5a90c)\"\n : \"linear-gradient(180deg, #8d97b3, #5d6a92)\",\n display: \"grid\",\n placeItems: \"center\",\n boxShadow: row.featured ? \"0 0 0 3px #ff243d\" : undefined,\n ...uText(row.required >= 1000 ? 10 : 13),\n }}\n >\n {formatAmount(row.required)}\n </span>\n </div>\n {hasVip ? (\n <div style={style}>\n <Bubble\n tone=\"premium\"\n lines={row.vip}\n dim={!reached}\n done={row.vipState === \"granted\"}\n locked={row.vip.length > 0 && row.vipState === \"locked\"}\n onLock={\n vipID && store.vipOnSale()\n ? () => store.openVip(vipID)\n : undefined\n }\n action={\n row.vipState === \"ready\"\n ? claimBtn(row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\"))\n : undefined\n }\n />\n </div>\n ) : null}\n </>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <UPopup tone=\"blue\" title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </UPopup>\n );\n}\n"
50
50
  },
51
51
  {
52
52
  "path": "i18n.ts",
53
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Events\",\n noEvents: \"No events right now — check back soon.\",\n endsIn: \"Ends in\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n vipPrize: \"VIP prize\",\n vipTopUp: \"Claim VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipUntil: \"until\",\n vipBenefit:\n \"A second prize on every milestone — also for the ones you already claimed.\",\n howToEarn: \"How to earn\",\n playRound: \"Play a round\",\n perDay: \"per day\",\n shopping: \"Buy in the shop\",\n quests: \"Finish a quest\",\n login: \"Log in\",\n other: \"Other activity\",\n nextPrize: \"Next prize at\",\n allDone: \"All prizes collected!\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Ивенты\",\n noEvents: \"Сейчас ивентов нет — загляните позже.\",\n endsIn: \"До конца\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n vipPrize: \"Приз VIP\",\n vipTopUp: \"Забрать VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipUntil: \"до\",\n vipBenefit: \"Второй приз на каждой вехе — и на тех, что вы уже забрали.\",\n howToEarn: \"Как получить\",\n playRound: \"Сыграйте раунд\",\n perDay: \"в день\",\n shopping: \"Покупка в магазине\",\n quests: \"Выполненное задание\",\n login: \"Вход в игру\",\n other: \"Другие действия\",\n nextPrize: \"Следующий приз на\",\n allDone: \"Все призы собраны!\",\n});\n"
53
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Events\",\n noEvents: \"No events right now — check back soon.\",\n endsIn: \"Ends in\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n vipPrize: \"VIP prize\",\n vipTopUp: \"Claim VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipUntil: \"until\",\n vipBenefit:\n \"A second prize on every milestone — also for the ones you already claimed.\",\n howToEarn: \"How to earn\",\n playRound: \"Play a round\",\n perDay: \"per day\",\n shopping: \"Buy in the shop\",\n quests: \"Finish a quest\",\n login: \"Log in\",\n other: \"Other activity\",\n nextPrize: \"Next prize at\",\n allDone: \"All prizes collected!\",\n activate: \"Activate\",\n free: \"FREE\",\n premium: \"PREMIUM\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Ивенты\",\n noEvents: \"Сейчас ивентов нет — загляните позже.\",\n endsIn: \"До конца\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n vipPrize: \"Приз VIP\",\n vipTopUp: \"Забрать VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipUntil: \"до\",\n vipBenefit: \"Второй приз на каждой вехе — и на тех, что вы уже забрали.\",\n howToEarn: \"Как получить\",\n playRound: \"Сыграйте раунд\",\n perDay: \"в день\",\n shopping: \"Покупка в магазине\",\n quests: \"Выполненное задание\",\n login: \"Вход в игру\",\n other: \"Другие действия\",\n nextPrize: \"Следующий приз на\",\n allDone: \"Все призы собраны!\",\n activate: \"Активировать\",\n free: \"FREE\",\n premium: \"PREMIUM\",\n});\n"
54
54
  },
55
55
  {
56
56
  "path": "index.ts",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  {
68
68
  "path": "module.meta.json",
69
- "content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
69
+ "content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
70
70
  }
71
71
  ]
72
72
  }
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.6",
39
- "@idosgames/module-sdk": "0.11.0",
40
- "@idosgames/react": "0.5.1",
38
+ "@idosgames/core": "0.22.1",
39
+ "@idosgames/module-sdk": "0.12.0",
40
+ "@idosgames/react": "0.5.2",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "4e73bcfda1814bd4cc118ca318056e2d32d238e392c5c9cc2002fa892c223433",
@@ -72,9 +72,9 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@idosgames/core": "0.21.6",
76
- "@idosgames/module-sdk": "0.11.0",
77
- "@idosgames/react": "0.5.1",
75
+ "@idosgames/core": "0.22.1",
76
+ "@idosgames/module-sdk": "0.12.0",
77
+ "@idosgames/react": "0.5.2",
78
78
  "phaser": "4.2.1",
79
79
  "react": "19.2.7"
80
80
  },
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.6",
36
- "@idosgames/module-sdk": "0.11.0",
37
- "@idosgames/react": "0.5.1",
35
+ "@idosgames/core": "0.22.1",
36
+ "@idosgames/module-sdk": "0.12.0",
37
+ "@idosgames/react": "0.5.2",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "0b02bc09a1a29ecb6d08fa281dfe316b01f8f341350ba27f386a05572827700e",
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.6",
39
- "@idosgames/module-sdk": "0.11.0",
40
- "@idosgames/react": "0.5.1",
38
+ "@idosgames/core": "0.22.1",
39
+ "@idosgames/module-sdk": "0.12.0",
40
+ "@idosgames/react": "0.5.2",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "7d314ec2a1801e22dbe5b2e2df731e7a582f8efb9cca0328de5e30b52cc27047",
@@ -33,31 +33,31 @@
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.21.6",
40
- "@idosgames/module-sdk": "0.11.0",
41
- "@idosgames/react": "0.5.1",
39
+ "@idosgames/core": "0.22.1",
40
+ "@idosgames/module-sdk": "0.12.0",
41
+ "@idosgames/react": "0.5.2",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "587cf8ee4685d907eaebfb1a0f96a7b269685cfefd990c05b057cf9626e75193",
44
+ "contentHash": "bbf36dc2922963131f620c9199f259de49fbb4ac3cd6a0d9a24f1f65485b1ee0",
45
45
  "files": [
46
46
  {
47
47
  "path": "components/CycleReward.tsx",
48
- "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n errorText,\n grantedBy,\n Icon,\n outlined,\n Popup,\n ResourceList,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n enabledBoards,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
48
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { type FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n configSection,\n errorText,\n grantedBy,\n Icon,\n UButton,\n UDivider,\n UItemFrame,\n UMedal,\n UPopup,\n URays,\n URibbon,\n UStyles,\n uText,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useToast,\n} from \"@idosgames/react/ui\";\nimport {\n enabledBoards,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <UPopup\n tone=\"blue\"\n title={t(\"lastCycle\")}\n onClose={onClose}\n variant=\"center\"\n queued\n width={420}\n >\n <UStyles />\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n style={{\n position: \"relative\",\n width: 150,\n height: 130,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", left: -15, top: -25 }}>\n <URays size={180} />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.4s ease-in-out infinite\",\n }}\n >\n {rank && rank <= 3 ? (\n <UMedal place={rank as 1 | 2 | 3} size={100} />\n ) : (\n <Icon glyph=\"trophy\" size={96} />\n )}\n </span>\n </div>\n <URibbon tone=\"orange\" width={280}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </URibbon>\n <div style={{ ...uText(15), textAlign: \"center\" }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)} ·{\" \"}\n {t(\"yourPlace\")}\n </div>\n {lines.length > 0 ? (\n <>\n <UDivider>{t(\"reward\")}</UDivider>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {lines.map((l, i) => (\n <UItemFrame key={i} line={l} size={70} />\n ))}\n </div>\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </UPopup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <UButton\n tone=\"green\"\n size=\"l\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </UButton>\n );\n}\n"
49
49
  },
50
50
  {
51
51
  "path": "components/MilestonesPopup.tsx",
52
- "content": "import { useState, type ReactNode } from \"react\";\nimport { type GetMyProgressResponse } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n errorText,\n grantedBy,\n Icon,\n outlined,\n Popup,\n ProgressBar,\n ResourceList,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n} from \"@idosgames/react/ui\";\nimport {\n cachedProgress,\n milestoneRows,\n type LeaderboardDefinitionView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n"
52
+ "content": "import { type ReactNode } from \"react\";\nimport { type GetMyProgressResponse } from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n errorText,\n grantedBy,\n UMilestonesPopup,\n useCelebrate,\n useToast,\n} from \"@idosgames/react/ui\";\nimport {\n cachedProgress,\n hasPremium,\n milestoneRows,\n type LeaderboardDefinitionView,\n type PremiumView,\n} from \"../model\";\nimport { t } from \"../i18n\";\n\n/** The board's score milestones in the kit's Unity rewards window (Leaderboard Milestones tab). */\nexport function MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n onOpenVip,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n /** Opens the VIP offer for this pass; absent when the title sells no VIP. */\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 earned = milestoneProgress(progress);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n premium,\n progress?.GrantedPremiumTiers,\n );\n const vipID = rows.find((r) => r.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, Date.now()) : false;\n\n return (\n <UMilestonesPopup\n title={t(\"milestones\")}\n glyph=\"trophy\"\n value={earned}\n milestones={rows.map((r) => ({\n id: r.id,\n required: r.required,\n free: r.reward,\n state: r.state,\n premium: r.vip,\n premiumState:\n r.vipState === \"granted\"\n ? \"claimed\"\n : r.vipState === \"ready\"\n ? \"ready\"\n : \"locked\",\n }))}\n premiumOwned={vipHeld}\n onPremiumLock={vipID && onOpenVip ? () => onOpenVip(vipID) : undefined}\n onClose={onClose}\n onClaim={async (milestoneID) => {\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(grantedBy(res.data));\n await onClaimed();\n return true;\n }}\n />\n );\n}\n\n/** What the milestones count: the tokens earned this cycle (the score when the board has none). */\nexport function milestoneProgress(\n progress: GetMyProgressResponse | null,\n): number {\n return Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n}\n"
53
53
  },
54
54
  {
55
55
  "path": "components/RankRewardsPopup.tsx",
56
- "content": "import { type ReactNode } from \"react\";\nimport {\n Card,\n grantLines,\n outlined,\n Popup,\n ResourceList,\n useStagger,\n} from \"@idosgames/react/ui\";\nimport { rewardForRank, type LeaderboardDefinitionView } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n"
56
+ "content": "import { type ReactNode } from \"react\";\nimport {\n grantLines,\n Icon,\n UMedal,\n UPopup,\n URankRow,\n formatAmount,\n uText,\n useCatalog,\n useStagger,\n} from \"@idosgames/react/ui\";\nimport { rewardForRank, type LeaderboardDefinitionView } from \"../model\";\nimport { t } from \"../i18n\";\n\n// Unity LeaderboardRewardsPopup (\"RANKING REWARDS\"): ListFrame02 rows — gold, silver, bronze for the\n// first three ranges with their medals, blue after — the range on the plate, the reward in the\n// oblique end. The player's own range carries a \"You\" tag.\n\nconst TONES = [\"gold\", \"silver\", \"bronze\"] as const;\n\nexport function RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const catalog = useCatalog();\n return (\n <UPopup\n title={t(\"rankRewards\")}\n icon=\"trophy\"\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <div\n key={r?.Rank ?? i}\n style={{ position: \"relative\", ...stagger(i) }}\n >\n <URankRow\n tone={TONES[i] ?? \"blue\"}\n height={62}\n rightWidth={lines.length > 1 ? 150 : 110}\n right={lines.slice(0, 2).map((l, k) => (\n <span\n key={k}\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 2,\n ...uText(14),\n }}\n >\n <Icon glyph={catalog.iconOf(l)} size={26} />\n {formatAmount(l.amount)}\n </span>\n ))}\n >\n {i < 3 ? (\n <UMedal place={(i + 1) as 1 | 2 | 3} size={44} />\n ) : (\n <span style={{ width: 35 }} />\n )}\n <span style={uText(20)}>#{r?.Rank}</span>\n </URankRow>\n {mineHere ? (\n <span\n style={{\n position: \"absolute\",\n left: -6,\n top: -8,\n padding: \"1px 8px\",\n borderRadius: 8,\n background: \"#ff243d\",\n border: \"2px solid #10172e\",\n ...uText(12),\n }}\n >\n {t(\"you\")}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n </UPopup>\n );\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "components/TopList.tsx",
60
- "content": "import { type ReactNode } from \"react\";\nimport { type LeaderboardUserEntry } from \"@idosgames/core\";\nimport {\n formatAmount,\n Icon,\n outlined,\n panel,\n toneSurface,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n ...(mine\n ? toneSurface(\"blue\")\n : { background: panel.background }),\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? v.wellSoft,\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n"
60
+ "content": "import { type ReactNode } from \"react\";\nimport { type LeaderboardUserEntry } from \"@idosgames/core\";\nimport {\n Icon,\n UAvatar,\n UFlag,\n UHexRank,\n UMedal,\n URankRow,\n formatAmount,\n uText,\n useStagger,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\n\n// The Unity LeaderboardWindow body: three podium flags (2nd blue on the left, 1st red in the middle,\n// 3rd green on the right — a profile frame, the name, the trophy with the points, the medal), then\n// LeaderboardEntry rows from the 4th place (a hexagon with the place, the profile frame, the name,\n// the trophy and the points in the oblique end); the player's own row is yellow.\n\nconst AVATAR_INNER = [\"#fdae4f\", \"#5fd6ff\", \"#6b8fff\", \"#f5cc47\"];\n\nexport function nameOf(\n entry: LeaderboardUserEntry,\n me: string | undefined,\n): string {\n return (\n entry.PublicProfile?.Username ||\n (entry.UserID === me\n ? t(\"you\")\n : `${t(\"player\")} ${entry.UserID.slice(-4)}`)\n );\n}\n\nexport function Podium({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const at = (place: number) =>\n users.find((u, i) => (u.Rank ?? i + 1) === place) ?? null;\n const slot = (place: 1 | 2 | 3, color: \"blue\" | \"red\" | \"green\") => {\n const entry = at(place);\n const name = entry ? nameOf(entry, me) : \"—\";\n return (\n <div\n style={{\n flex: 1,\n minWidth: 0,\n marginTop: place === 1 ? 0 : 14,\n animation: \"idos-u-pop .45s cubic-bezier(.3,1.6,.5,1) both\",\n animationDelay: `${(place === 1 ? 0 : place === 2 ? 0.1 : 0.2).toFixed(2)}s`,\n }}\n >\n <UFlag color={color} height={place === 1 ? 196 : 182}>\n <div style={{ position: \"relative\" }}>\n <UAvatar\n name={name}\n src={entry?.PublicProfile?.AvatarUrl}\n inner={AVATAR_INNER[place - 1]}\n size={50}\n />\n {entry?.PublicProfile?.Premium ? (\n <span style={{ position: \"absolute\", right: -10, top: -10 }}>\n <Icon glyph=\"crown\" size={20} />\n </span>\n ) : null}\n </div>\n <div\n style={{\n ...uText(14),\n maxWidth: \"88%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n marginTop: 2,\n }}\n >\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n ...uText(13),\n }}\n >\n <Icon glyph=\"trophy\" size={18} />\n {entry ? formatAmount(Number(entry.Score ?? 0)) : \"0\"}\n </div>\n <div style={{ marginTop: 2 }}>\n <UMedal place={place} size={46} />\n </div>\n </UFlag>\n </div>\n );\n };\n return (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n padding: \"0 10px\",\n alignItems: \"flex-start\",\n }}\n >\n {slot(2, \"blue\")}\n {slot(1, \"red\")}\n {slot(3, \"green\")}\n </div>\n );\n}\n\nexport function EntryRow({\n entry,\n place,\n me,\n}: {\n entry: LeaderboardUserEntry;\n place: number;\n me: string | undefined;\n}): ReactNode {\n const mine = entry.UserID === me;\n const name = nameOf(entry, me);\n const level = entry.PublicProfile?.Level;\n return (\n <URankRow\n tone={mine ? \"me\" : \"entry\"}\n height={62}\n rightWidth={116}\n right={\n <>\n <Icon glyph=\"trophy\" size={24} />\n <span style={uText(15)}>\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </>\n }\n >\n <div style={{ position: \"relative\" }}>\n <UHexRank color={mine ? \"#ffdb3d\" : \"#0a7ad9\"} size={42}>\n {place}\n </UHexRank>\n {entry.PublicProfile?.Premium ? (\n <span style={{ position: \"absolute\", left: 10, top: -12 }}>\n <Icon glyph=\"crown\" size={20} />\n </span>\n ) : null}\n </div>\n <UAvatar\n name={name}\n src={entry.PublicProfile?.AvatarUrl}\n inner={AVATAR_INNER[place % AVATAR_INNER.length]}\n size={44}\n />\n <div style={{ minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n ...uText(16),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n {level ? <span style={uText(11)}>Lv.{level}</span> : null}\n </div>\n </URankRow>\n );\n}\n\nexport function TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n const rest = users.slice(0, 100).filter((u, i) => (u.Rank ?? i + 1) > 3);\n return (\n <div style={{ display: \"grid\", gap: 8, padding: \"0 10px\" }}>\n {rest.map((entry, i) => (\n <div key={entry.UserID} style={stagger(i)}>\n <EntryRow entry={entry} place={entry.Rank ?? i + 4} me={me} />\n </div>\n ))}\n </div>\n );\n}\n"
61
61
  },
62
62
  {
63
63
  "path": "i18n.ts",
@@ -69,11 +69,11 @@
69
69
  },
70
70
  {
71
71
  "path": "LeaderboardScreen.tsx",
72
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type GetLeaderboardResponse,\n type GetMyProgressResponse,\n} from \"@idosgames/core\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Center,\n configSection,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n outlined,\n panel,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useGameLayout,\n useNow,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport { MilestonesPopup } from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { TopList } from \"./components/TopList\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n // The live place: the table when the player is in it — `LastKnownRank` of the progress is the place\n // at the last recount, 0 for a player who has not been recounted yet (the table said #1 while the\n // card said \"—\").\n const myRank =\n users.find((u) => u.UserID === me)?.Rank || progress?.LastKnownRank || null;\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"blue\"),\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat label={t(\"myRank\")} value={myRank ? `#${myRank}` : \"—\"} />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={myRank}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n"
72
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type GetLeaderboardResponse,\n type GetMyProgressResponse,\n} from \"@idosgames/core\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Center,\n configSection,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n Spinner,\n U,\n UButton,\n UIconSlider,\n URays,\n UScreen,\n UTabs,\n UTimer,\n UTopBar,\n uText,\n useCatalog,\n useNow,\n useToast,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport {\n MilestonesPopup,\n milestoneProgress,\n} from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { EntryRow, Podium, TopList } from \"./components/TopList\";\n\n// Leaderboards (Unity LeaderboardWindow, the Layer Lab look of Board_Survival): a violet top strip —\n// the dark title bar with my place in the black corner, the orange milestone slider with its timer\n// (tap → the milestone rewards) and the golden trophy over spinning rays (tap → the rank rewards);\n// three podium flags, the entries from the 4th place, the player's own yellow row pinned at the\n// bottom over the boards' pill tabs. A game that has something to PLAY for the score registers a\n// \"play\" feature, and a Play button appears here.\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const mine = users.find((u) => u.UserID === me);\n // The live place: the table when the player is in it — `LastKnownRank` of the progress is the place\n // at the last recount, 0 for a player who has not been recounted yet (the table said #1 while the\n // card said \"—\").\n const myRank = mine?.Rank || progress?.LastKnownRank || null;\n const myScore = Number(mine?.Score ?? progress?.CurrentScore ?? 0);\n\n // The slider runs to the next milestone not yet reached (Unity \"13/50\"), past the last — full.\n const earned = milestoneProgress(progress);\n const steps = Object.values(board.Milestones ?? {})\n .map((m) => Number(m?.RequiredProgress ?? 0))\n .filter((n) => n > 0)\n .sort((a, b) => a - b);\n const nextStep = steps.find((n) => n > earned) ?? steps[steps.length - 1];\n const hasRanks = (board.RankRewards?.length ?? 0) > 0;\n const left = countdown(top?.CycleEndUtc, now);\n\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n // Bottom: the player's row (ListFrame01_Yellow), the call to action, the board tabs.\n const footer = (\n <div\n style={{\n background: \"#022b73\",\n borderTop: `3px solid ${U.ink}`,\n }}\n >\n <div style={{ display: \"grid\", gap: 8, padding: \"10px 10px 0\" }}>\n {me ? (\n <EntryRow\n entry={\n mine ?? {\n UserID: me,\n Score: myScore,\n Rank: myRank ?? undefined,\n }\n }\n place={myRank ?? 0}\n me={me}\n />\n ) : null}\n {cta === \"play\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </UButton>\n ) : cta === \"invite\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={20} /> {t(\"invite\")}\n </UButton>\n ) : null}\n </div>\n {boards.length > 1 ? (\n <UTabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n ) : (\n <div style={{ height: 10 }} />\n )}\n </div>\n );\n\n return (\n <UScreen footer={footer}>\n {/* ── Top: the title bar, the milestone slider + timer, the rank-rewards trophy ── */}\n <div\n style={{ background: \"#5461ff\", borderBottom: `3px solid ${U.ink}` }}\n >\n <UTopBar\n title={catalog.localize(board.DisplayName ?? id)}\n cornerLabel={t(\"myRank\")}\n corner={myRank ? `#${myRank}` : \"—\"}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: hasRanks ? \"1fr 92px\" : \"1fr\",\n alignItems: \"center\",\n gap: 6,\n padding: \"12px 12px 10px\",\n }}\n >\n <div style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n {nextStep !== undefined ? (\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph=\"trophy\"\n value={Math.min(earned, nextStep)}\n max={nextStep}\n onClick={() => setPopup(\"milestones\")}\n />\n {progress?.HasUnclaimedMilestone ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: \"#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 ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {left ? <UTimer>{left}</UTimer> : null}\n {top?.TotalParticipants ? (\n <span style={uText(13)}>\n <Icon glyph=\"friends\" size={16} />{\" \"}\n {formatAmount(Number(top.TotalParticipants))}\n </span>\n ) : null}\n </div>\n </div>\n {hasRanks ? (\n <button\n type=\"button\"\n aria-label={t(\"rankRewards\")}\n onClick={() => setPopup(\"ranks\")}\n className=\"idos-press\"\n style={{\n position: \"relative\",\n width: 92,\n height: 92,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -14 }}>\n <URays size={120} color=\"rgba(255,250,120,.45)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.4s ease-in-out infinite\",\n filter: \"drop-shadow(0 4px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"trophy\" size={62} />\n </span>\n </button>\n ) : null}\n </div>\n </div>\n\n {/* ── Body: the podium and the entries ── */}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 12,\n padding: \"14px 0 12px\",\n }}\n >\n {loading ? (\n <Center minHeight={160}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <>\n <Podium users={users} me={me} />\n <TopList users={users} me={me} />\n </>\n )}\n </div>\n\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={myRank}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n onOpenVip={\n features.get(\"premium\")?.available\n ? (premiumID) => features.open(\"premium\", { premiumID })\n : undefined\n }\n />\n ) : null}\n </UScreen>\n );\n}\n"
73
73
  },
74
74
  {
75
75
  "path": "model.ts",
76
- "content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Score milestones of the current cycle, in order, with what is claimable. */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : progress >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n\n/** Whether the title has an enabled leaderboard — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LeaderboardSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(\n (d) =>\n d != null && (d as { IsEnabled?: boolean | null }).IsEnabled !== false,\n );\n}\n"
76
+ "content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n /** The VIP part of the milestone (`Rewards.PremiumTiers`), empty when it has none. */\n vip: ResourceLine[];\n /** The pass the VIP part asks for (the first one). */\n vipPremiumID: string | null;\n /**\n * none — no VIP part; locked — the player has no pass; ready — the pass is held, the milestone is\n * reached and the part is not granted yet (with the base, or topped up later); granted — taken.\n */\n vipState: \"none\" | \"locked\" | \"ready\" | \"granted\";\n}\n\n/** The player's premium passes, as the client caches them (`state.Premium`). */\nexport interface PremiumView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null\n > | null;\n MaxActiveTier?: number | null;\n}\n\ntype PremiumTier = {\n RequiredPremiumID?: string | null;\n MinPremiumTier?: number | null;\n Resources?: unknown;\n};\n\n/** Does the player hold the pass a premium bundle asks for (a named pass, or any pass of a tier)? */\nexport function hasPremium(\n premium: PremiumView | null | undefined,\n premiumID: string | null | undefined,\n minTier: number | null | undefined,\n nowMs: number,\n): boolean {\n if (premiumID) {\n const until = premium?.Subscriptions?.[premiumID]?.ExpirationDate;\n return !!until && new Date(until).getTime() > nowMs;\n }\n return (\n Number(premium?.MaxActiveTier ?? 0) >= Math.max(1, Number(minTier ?? 1))\n );\n}\n\n/** The server's key of a granted premium bundle: `{RequiredPremiumID|*}#{MinPremiumTier}`. */\nexport function premiumKey(tier: PremiumTier): string {\n return `${tier.RequiredPremiumID || \"*\"}#${Number(tier.MinPremiumTier ?? 0)}`;\n}\n\n/**\n * Score milestones of the current cycle, in order, with what is claimable — the base part and the\n * VIP part. `granted` is the server's ledger of VIP parts already given (`GrantedPremiumTiers`): a\n * pass bought after the claim tops the milestone up (the engine's MilestonePremiumLedger).\n */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n premium?: PremiumView | null,\n granted?: Record<string, string[] | null | undefined> | null,\n nowMs: number = Date.now(),\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n const reached = progress >= required;\n const tiers = ((\n m.Rewards as { PremiumTiers?: PremiumTier[] | null } | null\n )?.PremiumTiers ?? []) as PremiumTier[];\n const given = granted?.[id] ?? [];\n const held = tiers.filter((t) =>\n hasPremium(premium, t.RequiredPremiumID, t.MinPremiumTier, nowMs),\n );\n let vipState: MilestoneRow[\"vipState\"] = \"locked\";\n if (tiers.length === 0) vipState = \"none\";\n else if (tiers.every((t) => given.includes(premiumKey(t))))\n vipState = \"granted\";\n else if (reached && held.some((t) => !given.includes(premiumKey(t))))\n vipState = \"ready\";\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id) ? \"claimed\" : reached ? \"ready\" : \"locked\",\n vip: tiers.flatMap((t) => bundleLines(t.Resources as never)),\n vipPremiumID:\n tiers.find((t) => t.RequiredPremiumID)?.RequiredPremiumID ?? null,\n vipState,\n } satisfies MilestoneRow;\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n\n/** Whether the title has an enabled leaderboard — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LeaderboardSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(\n (d) =>\n d != null && (d as { IsEnabled?: boolean | null }).IsEnabled !== false,\n );\n}\n"
77
77
  },
78
78
  {
79
79
  "path": "module.ts",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  {
83
83
  "path": "module.meta.json",
84
- "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
84
+ "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
85
85
  }
86
86
  ]
87
87
  }