@idosgames/mcp 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +144 -144
  5. package/registry/modules/ai.json +5 -5
  6. package/registry/modules/board-game.json +15 -15
  7. package/registry/modules/boosts.json +5 -5
  8. package/registry/modules/character.json +11 -11
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +5 -5
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +5 -5
  13. package/registry/modules/deals.json +6 -6
  14. package/registry/modules/events.json +7 -7
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/hero-run.json +21 -17
  17. package/registry/modules/idle-rpg.json +20 -16
  18. package/registry/modules/inventory.json +3 -3
  19. package/registry/modules/invite.json +3 -3
  20. package/registry/modules/leaderboard.json +7 -7
  21. package/registry/modules/lootboxes.json +7 -7
  22. package/registry/modules/mailbox.json +7 -7
  23. package/registry/modules/marketplace.json +3 -3
  24. package/registry/modules/mini-game.json +7 -7
  25. package/registry/modules/multiplayer.json +3 -3
  26. package/registry/modules/premium.json +5 -5
  27. package/registry/modules/profile.json +3 -3
  28. package/registry/modules/pvp.json +3 -3
  29. package/registry/modules/quests.json +3 -3
  30. package/registry/modules/rewards.json +5 -5
  31. package/registry/modules/season.json +3 -3
  32. package/registry/modules/store.json +9 -9
  33. package/registry/modules/tutorials.json +7 -7
  34. package/registry/modules/video-studio.json +9 -9
  35. package/registry/modules/voxelcraft.json +37 -29
  36. package/registry/modules/workshop.json +6 -6
  37. package/registry/skills/ai-generation-system.json +2 -2
  38. package/registry/skills/idosgames-ui-kit.json +1 -1
  39. package/registry/skills/rewarded-ads.json +1 -1
  40. package/registry/skills/store-system.json +1 -1
@@ -34,19 +34,19 @@
34
34
  "name": "iDos Games",
35
35
  "url": "https://idosgames.com"
36
36
  },
37
- "version": "0.1.4"
37
+ "version": "0.1.5"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.24.3",
41
- "@idosgames/module-sdk": "0.13.1",
42
- "@idosgames/react": "0.6.2",
40
+ "@idosgames/core": "0.26.0",
41
+ "@idosgames/module-sdk": "0.13.3",
42
+ "@idosgames/react": "0.7.0",
43
43
  "react": "19.2.7"
44
44
  },
45
- "contentHash": "70e2cc84a42ee818deaccf7c39ec87e826d7f22e71fc05346f40be96a6f29e6b",
45
+ "contentHash": "97ba661e41bf3aa20fa109b3a102907fb94cac6bb66a4e101e7a0d539c78e4e9",
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 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={v.blue}\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: `radial-gradient(ellipse at 50% 30%, ${v.bgTop} 0%, ${v.blue} 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, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 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: v.well,\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: v.well,\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: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.blue} 55%, #fff) 0%, ${v.blue} 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"
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={v.blue}\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: `radial-gradient(ellipse at 50% 30%, ${v.bgTop} 0%, ${v.blue} 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 }}\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, color-mix(in srgb, ${v.nav} 85%, #fff) 0%, ${v.nav} 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: \"inset 0 3px 0 rgba(255,255,255,.5)\",\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: v.well,\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: v.well,\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 }}\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: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.blue} 55%, #fff) 0%, ${v.blue} 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: \"inset 0 3px 0 rgba(255,255,255,.5)\",\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: `none`,\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 }}\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",
@@ -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.4\"\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.5\"\n}\n"
70
70
  }
71
71
  ]
72
72
  }
@@ -35,9 +35,9 @@
35
35
  "version": "0.1.3"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.24.3",
39
- "@idosgames/module-sdk": "0.13.1",
40
- "@idosgames/react": "0.6.2",
38
+ "@idosgames/core": "0.26.0",
39
+ "@idosgames/module-sdk": "0.13.3",
40
+ "@idosgames/react": "0.7.0",
41
41
  "react": "19.2.7"
42
42
  },
43
43
  "contentHash": "cb4b66936fa31747a116946730b9fc2576262e68220570b149de0dbf3bb9a943",