@idosgames/mcp 0.1.17 → 0.1.19

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 +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +74 -22
  4. package/registry/index.json +761 -115
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/character.json +75 -0
  7. package/registry/modules/chat.json +71 -0
  8. package/registry/modules/craft.json +71 -0
  9. package/registry/modules/deals.json +70 -0
  10. package/registry/modules/events.json +77 -0
  11. package/registry/modules/friends.json +70 -0
  12. package/registry/modules/idle-rpg.json +8 -8
  13. package/registry/modules/inventory.json +70 -0
  14. package/registry/modules/invite.json +70 -0
  15. package/registry/modules/leaderboard.json +72 -0
  16. package/registry/modules/marketplace.json +72 -0
  17. package/registry/modules/mini-game.json +69 -0
  18. package/registry/modules/profile.json +71 -0
  19. package/registry/modules/pvp.json +69 -0
  20. package/registry/modules/rewards.json +72 -0
  21. package/registry/modules/season.json +76 -0
  22. package/registry/modules/store.json +72 -0
  23. package/registry/modules/tutorials.json +68 -0
  24. package/registry/modules/voxelcraft.json +65 -464
  25. package/registry/modules/workshop.json +13 -8
  26. package/registry/skills/dev-test-loop.json +1 -1
  27. package/registry/skills/idosgames-compose-modules.json +2 -2
  28. package/registry/skills/idosgames-getting-started.json +2 -2
  29. package/registry/skills/idosgames-module-contract.json +2 -2
  30. package/registry/skills/idosgames-project-structure.json +1 -1
  31. package/registry/skills/idosgames-ui-kit.json +6 -0
  32. package/registry/skills/leaderboard-system.json +1 -1
  33. package/registry/skills/multiplayer-realtime.json +3 -3
  34. package/registry/skills/push-notifications.json +1 -1
  35. package/registry/skills/referral-system.json +2 -2
  36. package/registry/skills/timed-event-system.json +1 -1
  37. package/registry/skills/workshop-system.json +1 -1
  38. package/registry/modules/game-hud.json +0 -99
@@ -0,0 +1,72 @@
1
+ {
2
+ "id": "rewards",
3
+ "meta": {
4
+ "name": "Rewards",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.",
12
+ "description": "A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.",
13
+ "provides": [
14
+ "daily login rewards calendar",
15
+ "daily bonus popup on entry",
16
+ "quests by cycle with progress",
17
+ "quest details popup",
18
+ "claim all finished quests",
19
+ "quest points track with milestone chests",
20
+ "badge for waiting rewards",
21
+ "demo daily calendar and quests"
22
+ ],
23
+ "tags": [
24
+ "daily-rewards",
25
+ "calendar",
26
+ "quests",
27
+ "missions",
28
+ "achievements",
29
+ "ui",
30
+ "screen",
31
+ "events"
32
+ ],
33
+ "author": {
34
+ "name": "iDos Games",
35
+ "url": "https://idosgames.com"
36
+ },
37
+ "version": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@idosgames/core": "0.19.0",
41
+ "@idosgames/module-sdk": "0.6.0",
42
+ "@idosgames/react": "0.3.0",
43
+ "react": "19.2.7"
44
+ },
45
+ "contentHash": "f7f8672832798aa56674aa1a5705fac1a036a8dd7cd6febfd3a930bd953b5b41",
46
+ "files": [
47
+ {
48
+ "path": "i18n.ts",
49
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Rewards\",\n daily: \"Daily rewards\",\n day: \"Day\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n comeBackTomorrow: \"Come back tomorrow for the next reward\",\n noCalendar: \"No daily calendar. Add one in LiveOps → Rewards.\",\n quests: \"Quests\",\n permanent: \"Achievements\",\n noQuests: \"No quests. Add them in LiveOps → Quests.\",\n questsReset: \"New quests in\",\n details: \"Quest\",\n objectives: \"Goals\",\n reward: \"Reward\",\n points: \"Quest points\",\n done: \"Done\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Награды\",\n daily: \"Ежедневные награды\",\n day: \"День\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n comeBackTomorrow: \"Возвращайтесь завтра за следующей наградой\",\n noCalendar: \"Ежедневного календаря нет. Добавьте его в LiveOps → Rewards.\",\n quests: \"Задания\",\n permanent: \"Достижения\",\n noQuests: \"Заданий нет. Добавьте их в LiveOps → Quests.\",\n questsReset: \"Новые задания через\",\n details: \"Задание\",\n objectives: \"Цели\",\n reward: \"Награда\",\n points: \"Очки заданий\",\n done: \"Выполнено\",\n});\n"
50
+ },
51
+ {
52
+ "path": "index.ts",
53
+ "content": "// @idosgames/mod-rewards — Rewards screen for any game: the daily login calendar that greets the player, quests by cycle with details and the quest points track.\n\nexport { rewardsModule } from \"./module\";\n"
54
+ },
55
+ {
56
+ "path": "model.ts",
57
+ "content": "// Pure helpers of the rewards screen: the daily calendar arithmetic, quest rows and the quest points\n// track. No React, no client calls — covered by model.test.ts against the module's own demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n grantLines,\n mergeLines,\n type Layout,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface DailyDayView {\n DayNumber?: number;\n Rewards?: ResourceGrant | null;\n IsMilestone?: boolean;\n}\n\nexport interface DailyCalendarView {\n CalendarID?: string;\n DisplayName?: string | null;\n Days?: DailyDayView[] | null;\n IsLooping?: boolean;\n ClaimMode?: string;\n ClaimCooldownSeconds?: number;\n}\n\nexport interface RewardSection {\n DailyCalendars?: Record<string, DailyCalendarView | null> | null;\n}\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardSection | undefined,\n): (DailyCalendarView & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is DailyCalendarView & { CalendarID: string } =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyDayView; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarView,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 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 : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the rewards screen: the daily bonus waiting + quests and track rewards ready. */\nexport function rewardsBadge(\n dailyReady: boolean,\n rows: QuestRow[],\n trackReady: number,\n): number {\n return (\n (dailyReady ? 1 : 0) +\n rows.filter((r) => r.status === \"Completed\").length +\n trackReady\n );\n}\n\n/** Whether the title has a daily calendar or quests — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n reward: RewardSection | undefined,\n quest: QuestSection | undefined,\n): boolean {\n return (\n Object.keys(reward?.DailyCalendars ?? {}).length > 0 ||\n Object.keys(quest?.Quests ?? {}).length > 0\n );\n}\n"
58
+ },
59
+ {
60
+ "path": "module.ts",
61
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { hasTitleData, type QuestSection, type RewardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Rewards as a feature of the game: the daily calendar and the quests. The always-on watcher greets\n// the player with the daily bonus once per session and keeps the badge.\nexport const rewardsModule: Module = defineModule({\n id: \"rewards\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n available: false,\n Screen: RewardsScreen,\n });\n ctx.features.setAvailable(\n \"rewards\",\n hasTitleData(\n configSection<RewardSection>(ctx.client, \"Reward\"),\n configSection<QuestSection>(ctx.client, \"Quest\"),\n ),\n \"no daily calendar (Reward) and no quests (Quest) on the title\",\n );\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
62
+ },
63
+ {
64
+ "path": "RewardsScreen.tsx",
65
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n mergeLines,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n claimableRewards,\n dailyView,\n lastCardSpan,\n pickCalendar,\n questRows,\n questTabs,\n rewardsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n type RewardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Rewards (Unity Alikhan/Dailies + Runtime/UI/Quests): the daily calendar — collected / today /\n// locked cards, the last one a big milestone — and the quests by cycle, with the quest points track\n// on top. A quest's goals and reward open in a popup; \"Claim all\" collects every finished quest.\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const layout = useGameLayout();\n const state = useUserState();\n\n // Fresh state on open: quests refresh their cycle on the server (a new day = new quests).\n useEffect(() => {\n void client.reward.getUserRewardsState();\n }, [client]);\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 18,\n alignItems: \"start\",\n gridTemplateColumns:\n layout === \"desktop\"\n ? \"minmax(0, 1fr) minmax(0, 1fr)\"\n : \"minmax(0, 1fr)\",\n }}\n >\n <DailyCalendar rewardState={state?.Reward ?? null} />\n <Quests />\n </div>\n );\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\ntype RewardState = {\n DailyCalendars?: Record<\n string,\n { CollectedDays?: number; LastClaimAt?: string } | undefined\n >;\n} | null;\n\nexport function DailyCalendar({\n rewardState,\n compact = false,\n onClaimed,\n}: {\n rewardState: RewardState;\n compact?: boolean;\n onClaimed?: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const layout = useGameLayout();\n const kit = useUiKit();\n const stagger = useStagger();\n const calendar = pickCalendar(configSection<RewardSection>(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n void client.quest.getUserQuestState();\n };\n\n return (\n <div>\n {compact ? null : (\n <SectionTitle>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </SectionTitle>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n return (\n <div\n key={day.DayNumber ?? i}\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n borderRadius: 14,\n padding: \"8px 4px\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n position: \"relative\",\n background:\n state === \"collected\"\n ? \"linear-gradient(180deg, #2f6e3a, #1d4a27)\"\n : today\n ? `linear-gradient(180deg, ${v.gold}, ${v.goldDeep})`\n : day.IsMilestone\n ? \"linear-gradient(180deg, #7b4be0, #3f2699)\"\n : \"rgba(0,0,0,.28)\",\n border: today\n ? \"2px solid #fff\"\n : \"2px solid rgba(255,255,255,.08)\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 12 }}>\n {t(\"day\")} {day.DayNumber ?? i + 1}\n </span>\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 36 : 30}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...outlined, fontSize: 12 }}>\n ×{main.amount}\n </span>\n ) : null}\n {state === \"collected\" ? (\n <span style={{ position: \"absolute\", top: -6, right: -6 }}>\n <Icon glyph=\"check\" size={22} />\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = current ? questState?.Cycles?.[current]?.CycleEndUtc : null;\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the daily bonus greets the player, and the badge ─────────────────────────────\n\n/**\n * Like the Unity DailyBonusAutoOpener: a claimable daily reward pops up once per session. Also keeps\n * the badge of the rewards screen (daily bonus + finished quests + track rewards).\n */\nexport function makeRewardsWatcher(features: FeatureRegistry) {\n return function RewardsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [popup, setPopup] = useState(false);\n const shownOnce = useRef(false);\n\n useEffect(() => {\n let cancelled = false;\n void client.reward.getUserRewardsState().then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n if (!calendar) return;\n const s = res.data.Rewards?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n void client.quest.getUserQuestState();\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change.\n useEffect(() => {\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"rewards\", rewardsBadge(dailyReady, rows, 0));\n }, [client, state]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
66
+ },
67
+ {
68
+ "path": "module.meta.json",
69
+ "content": "{\n \"id\": \"rewards\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.\",\n \"description\": \"A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for waiting rewards\",\n \"demo daily calendar and quests\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"quests\",\n \"missions\",\n \"achievements\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "id": "season",
3
+ "meta": {
4
+ "name": "Season",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.",
12
+ "description": "A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. The VIP pass (benefits, prices in currencies) is a popup. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.",
13
+ "provides": [
14
+ "season screen with a tab per chain",
15
+ "season timer and status bar",
16
+ "tier ladder with rewards",
17
+ "claim all reached tiers",
18
+ "VIP second prize on tiers",
19
+ "VIP pass purchase popup",
20
+ "season badge",
21
+ "demo seasons"
22
+ ],
23
+ "tags": [
24
+ "season",
25
+ "battle-pass",
26
+ "tiers",
27
+ "progression",
28
+ "live-ops",
29
+ "vip",
30
+ "ui",
31
+ "screen"
32
+ ],
33
+ "author": {
34
+ "name": "iDos Games",
35
+ "url": "https://idosgames.com"
36
+ },
37
+ "version": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@idosgames/core": "0.19.0",
41
+ "@idosgames/module-sdk": "0.6.0",
42
+ "@idosgames/react": "0.3.0",
43
+ "react": "19.2.7"
44
+ },
45
+ "contentHash": "2c1033ffa9a9f428fffce4a01e3a650069fc7d022d90c2b0d6e7426c2c73d245",
46
+ "files": [
47
+ {
48
+ "path": "i18n.ts",
49
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Season\",\n noSeason: \"No season is running right now.\",\n endsIn: \"Ends in\",\n tier: \"Tier\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n current: \"You are here\",\n status: \"Status\",\n nextTier: \"Next tier at\",\n maxTier: \"Top tier reached!\",\n vip: \"VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipBenefit: \"A second prize on every tier you claim while VIP is active.\",\n vipMissed: \"Claimed without VIP\",\n buy: \"Buy\",\n days: \"days\",\n howToEarn: \"Earn status with quests, events and play.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Сезон\",\n noSeason: \"Сейчас сезон не идёт.\",\n endsIn: \"До конца\",\n tier: \"Ступень\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n current: \"Вы здесь\",\n status: \"Статус\",\n nextTier: \"Следующая ступень на\",\n maxTier: \"Высшая ступень!\",\n vip: \"VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipBenefit:\n \"Второй приз на каждой ступени, которую вы забираете с активным VIP.\",\n vipMissed: \"Забрано без VIP\",\n buy: \"Купить\",\n days: \"дн.\",\n howToEarn: \"Статус дают задания, ивенты и игра.\",\n});\n"
50
+ },
51
+ {
52
+ "path": "index.ts",
53
+ "content": "// @idosgames/mod-season — Seasons for any game: status tiers with a reward each, the season timer, claim all and a VIP second prize.\n\nexport { seasonModule } from \"./module\";\n"
54
+ },
55
+ {
56
+ "path": "model.ts",
57
+ "content": "// Pure helpers of the season screen (Unity Core/Window MilestoneRewards): the tiers of the active\n// season, what the player can claim, and the VIP second prize. The server resolves the season window\n// and the player's tier; a tier's VIP part is paid only if the pass is held when the tier is claimed.\n// Covered by model.test.ts.\n\nimport type { ActiveSeasonInfo } from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\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\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\nexport interface TierRow {\n tier: number;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n vip: ResourceLine[];\n vipPremiumID: string | null;\n /** The pass is held now, so claiming pays the VIP part too. */\n vipHeld: boolean;\n state: \"claimed\" | \"ready\" | \"locked\";\n current: boolean;\n}\n\nexport function tierRows(\n info: ActiveSeasonInfo,\n premium: PremiumView | null | undefined,\n nowMs: number,\n): TierRow[] {\n const current = Number(info.UserState?.CurrentTier ?? 1);\n const claimed = info.UserState?.ClaimedTierRewards ?? [];\n return (info.Season?.Tiers ?? [])\n .filter((tier) => tier && Number(tier.Tier ?? 0) > 0)\n .map((tier) => {\n const n = Number(tier.Tier);\n const premiumTiers = (tier.TierReachedReward?.PremiumTiers ??\n []) as PremiumTier[];\n return {\n tier: n,\n name: tier.DisplayName ?? null,\n required: Number(tier.RequiredTokens ?? 0),\n reward: grantLines(tier.TierReachedReward),\n vip: premiumTiers.flatMap((p) => bundleLines(p.Resources as never)),\n vipPremiumID:\n premiumTiers.find((p) => p.RequiredPremiumID)?.RequiredPremiumID ??\n null,\n vipHeld: premiumTiers.some((p) =>\n hasPremium(premium, p.RequiredPremiumID, p.MinPremiumTier, nowMs),\n ),\n state: claimed.includes(n)\n ? \"claimed\"\n : n <= current && tier.TierReachedReward\n ? \"ready\"\n : \"locked\",\n current: n === current,\n } satisfies TierRow;\n })\n .sort((a, b) => a.tier - b.tier);\n}\n\n/** Status tokens: the cached bucket, never below the threshold of the tier the server says is reached. */\nexport function seasonTokens(\n info: ActiveSeasonInfo,\n cached: number | null | undefined,\n): number {\n const current = Number(info.UserState?.CurrentTier ?? 1);\n const floor = Number(\n (info.Season?.Tiers ?? []).find((t) => Number(t?.Tier) === current)\n ?.RequiredTokens ?? 0,\n );\n return Math.max(Number(cached ?? 0), floor);\n}\n\n/** The bar from the current tier's threshold to the next one. */\nexport function tierProgress(\n rows: TierRow[],\n tokens: number,\n): { from: number; goal: number; next: TierRow | null } {\n const next = rows.find((r) => r.required > tokens) ?? null;\n const prev = [...rows].reverse().find((r) => r.required <= tokens);\n return {\n from: prev?.required ?? 0,\n goal: next?.required ?? prev?.required ?? 0,\n next,\n };\n}\n\nexport function readyTiers(rows: TierRow[]): number[] {\n return rows.filter((r) => r.state === \"ready\").map((r) => r.tier);\n}\n\n/** Badge: tier rewards waiting across the active seasons. */\nexport function seasonBadge(\n seasons: ActiveSeasonInfo[],\n premium: PremiumView | null | undefined,\n nowMs: number,\n): number {\n return seasons.reduce(\n (n, s) => n + readyTiers(tierRows(s, premium, nowMs)).length,\n 0,\n );\n}\n\n/**\n * Everything a claim paid, for the reward popup: the standard part plus every premium (VIP) bundle\n * of the applied operation, summed by resource. The generic reader takes only the standard part,\n * so a VIP prize paid with the base would not show. `null` = the response has no operation.\n */\nexport function claimedLines(data: unknown): ResourceLine[] | null {\n const d = data as\n { Resources?: OpView | null; Rewards?: OpView | null } | null | undefined;\n const op = d?.Resources ?? d?.Rewards;\n if (!op?.Grant) return null;\n const merged = new Map<string, ResourceLine>();\n const add = (lines: ResourceLine[]) => {\n for (const l of lines) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n };\n add(bundleLines(op.Grant.Standard as never));\n for (const t of op.Grant.PremiumTiers ?? [])\n add(bundleLines(t?.Resources as never));\n return [...merged.values()];\n}\ntype OpView = {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n};\n"
58
+ },
59
+ {
60
+ "path": "module.ts",
61
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createSeasonStore,\n makeSeasonScreen,\n makeSeasonWatcher,\n} from \"./SeasonScreen\";\nimport { t } from \"./i18n\";\n\n// Seasons as a feature of the game. The always-on watcher keeps the badge current (tier rewards\n// waiting) and reloads after status tokens are granted; the screen shows each chain's active season.\nexport const seasonModule: Module = defineModule({\n id: \"season\",\n meta: { name: \"Season\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createSeasonStore(ctx.features);\n ctx.features.register({\n id: \"season\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 26,\n available: false,\n Screen: makeSeasonScreen(store),\n });\n // The tab shows while a season is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"season\",\n (store.get()?.length ?? 0) > 0,\n \"no active season right now (Season chains)\",\n ),\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeSeasonWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"season\",\n chains: store.get()?.map((s) => s.info.SeasonChainID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
62
+ },
63
+ {
64
+ "path": "SeasonScreen.tsx",
65
+ "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveSeasonInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n type PremiumView,\n type TierRow,\n} from \"./model\";\nimport { VipPopup } from \"./VipPopup\";\nimport { t } from \"./i18n\";\n\n// Seasons (Unity Core/Window MilestoneRewards): the active season of every chain — its timer, the\n// status bar toward the next tier and the tier ladder. Each tier pays once; a VIP gets a second\n// prize on the tiers claimed while the pass is active. The VIP pass is a popup.\n\ntype SeasonEntry = { info: ActiveSeasonInfo };\n\n/** Active seasons of every chain, shared by the screen and the badge watcher. */\nexport function createSeasonStore(features: FeatureRegistry) {\n let seasons: SeasonEntry[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"season\",\n seasons\n ? seasonBadge(\n seasons.map((s) => s.info),\n premium(),\n Date.now(),\n )\n : 0,\n );\n return {\n get: () => seasons,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const defs = await client.season.getDefinitions();\n const chains = defs.ok ? Object.keys(defs.data.Chains ?? {}) : [];\n const active = await Promise.all(\n chains.map((c) => client.season.getActiveSeason(c)),\n );\n seasons = active.flatMap((r) => (r.ok ? [{ info: r.data }] : []));\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n };\n}\nexport type SeasonStore = ReturnType<typeof createSeasonStore>;\n\nexport function makeSeasonScreen(store: SeasonStore) {\n return function SeasonScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const seasons = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!seasons)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (seasons.length === 0)\n return <EmptyState glyph=\"trophy\" text={t(\"noSeason\")} />;\n const current =\n seasons.find((s) => s.info.SeasonChainID === tab) ?? seasons[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={seasons.map((s) => ({\n id: s.info.SeasonChainID,\n label: catalog.localize(\n s.info.Season?.DisplayName ?? s.info.SeasonChainID,\n ),\n }))}\n value={current.info.SeasonChainID}\n onChange={setTab}\n />\n <SeasonView\n key={current.info.SeasonChainID}\n info={current.info}\n store={store}\n />\n </div>\n );\n };\n}\n\nexport function makeSeasonWatcher(store: SeasonStore) {\n return function SeasonWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n const off = client.on(\n \"season:statusTokensGranted\",\n () => void store.load(client),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction SeasonView({\n info,\n store,\n}: {\n info: ActiveSeasonInfo;\n store: SeasonStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [vip, setVip] = useState<string | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = tierRows(info, premium, now);\n const cached = (\n state?.EventToken?.Season as\n Record<string, { Balance?: { TotalEarned?: number } }> | undefined\n )?.[info.SeasonChainID]?.Balance?.TotalEarned;\n const tokens = seasonTokens(info, cached);\n const bar = tierProgress(rows, tokens);\n const ready = readyTiers(rows);\n const vipID = rows.find((r) => r.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = rows.some((r) => r.vipHeld);\n const currentRow = rows.find((r) => r.current);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.season.claimTierRewardsBatch(\n info.SeasonChainID,\n ready,\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : rows\n .filter((r) => r.state === \"ready\")\n .flatMap((r) => [...r.reward, ...(r.vipHeld ? r.vip : [])]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #3b7be0 0%, #1d3f8f 100%)\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph={info.Season?.AssetPaths?.icon ?? \"trophy\"} size={46} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(info.Season?.DisplayName ?? info.SeasonChainID)}\n </div>\n <Timer at={info.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n {currentRow ? (\n <div style={{ textAlign: \"right\", ...outlined }}>\n <div style={{ fontSize: 11, color: \"rgba(255,255,255,.8)\" }}>\n {t(\"tier\")}\n </div>\n <div style={{ fontSize: 20 }}>\n {currentRow.name\n ? catalog.localize(currentRow.name)\n : currentRow.tier}\n </div>\n </div>\n ) : null}\n </div>\n {info.Season?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: \"rgba(255,255,255,.85)\",\n }}\n >\n {catalog.localize(info.Season.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"status\")}: {formatAmount(tokens)}\n </span>\n <span>\n {bar.next\n ? `${t(\"nextTier\")} ${formatAmount(bar.goal)}`\n : t(\"maxTier\")}\n </span>\n </div>\n <ProgressBar\n value={bar.next ? tokens : 1}\n max={bar.next ? Math.max(1, bar.goal) : 1}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID ? (\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={() => setVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"season:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <TierCard\n key={row.tier}\n info={info}\n row={row}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {vip ? <VipPopup premiumID={vip} onClose={() => setVip(null)} /> : null}\n </div>\n );\n}\n\nfunction TierCard({\n info,\n row,\n store,\n style,\n}: {\n info: ActiveSeasonInfo;\n row: TierRow;\n store: SeasonStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.season.claimTierReward(\n info.SeasonChainID,\n row.tier,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0 ? got : [...row.reward, ...(row.vipHeld ? row.vip : [])],\n );\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border:\n row.state === \"ready\"\n ? `2px solid ${v.gold}`\n : row.current\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 56 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"trophy\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.name ? catalog.localize(row.name) : `${t(\"tier\")} ${row.tier}`}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.current ? (\n <div style={{ ...outlined, fontSize: 11, color: v.blue }}>\n {t(\"current\")}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipHeld && row.state !== \"claimed\" ? 1 : 0.5,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n </div>\n ) : null}\n </div>\n <div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`season:claim:${row.tier}`}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n"
66
+ },
67
+ {
68
+ "path": "VipPopup.tsx",
69
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n canAfford,\n costLines,\n errorText,\n outlined,\n panel,\n usePopupClose,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The VIP pass (Unity PremiumPopUp): what it gives and a price option to buy it with currencies.\n// A pass paid in a store (real money) is not sold here — that is the platform's checkout.\n\n/** The part of a premium definition (config section Premium) the popup reads. */\ntype PremiumDefinition = {\n PremiumID?: string | null;\n DisplayName?: string | null;\n DurationDays?: number | null;\n Benefits?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n};\n\nexport function VipPopup({\n premiumID,\n onClose,\n}: {\n premiumID: string;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [def, setDef] = useState<PremiumDefinition | null | undefined>(\n undefined,\n );\n useEffect(() => {\n void client.premium\n .getDefinitions()\n .then((res) =>\n setDef(\n res.ok\n ? ((res.data.Premium?.Definitions?.[premiumID] as\n PremiumDefinition | undefined) ?? null)\n : null,\n ),\n );\n }, [client, premiumID]);\n return (\n <Popup\n title={def?.DisplayName ?? t(\"vip\")}\n onClose={onClose}\n width={420}\n variant=\"center\"\n >\n {def === undefined ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : def === null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n —\n </div>\n ) : (\n <VipOffer def={def} />\n )}\n </Popup>\n );\n}\n\nfunction VipOffer({ def }: { def: PremiumDefinition }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const options = Object.entries(def.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([id, o]) => ({\n id: o?.OptionID || id,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0);\n\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n def.PremiumID!,\n crypto.randomUUID(),\n optionID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n toast(t(\"vipActive\"), \"success\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={64} />\n </div>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"vipBenefit\")}\n </div>\n {Object.values(def.Benefits ?? {}).length > 0 ? (\n <div style={{ display: \"grid\", gap: 4, width: \"100%\" }}>\n {Object.values(def.Benefits ?? {}).map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {b}\n </div>\n ))}\n </div>\n ) : null}\n {Number(def.DurationDays ?? 0) > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {def.DurationDays} {t(\"days\")}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8, width: \"100%\" }}>\n {options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor=\"vip:buy\"\n >\n {t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </div>\n </div>\n );\n}\n"
70
+ },
71
+ {
72
+ "path": "module.meta.json",
73
+ "content": "{\n \"id\": \"season\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. The VIP pass (benefits, prices in currencies) is a popup. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"VIP pass purchase popup\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
74
+ }
75
+ ]
76
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "id": "store",
3
+ "meta": {
4
+ "name": "Shop",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.",
12
+ "description": "A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.",
13
+ "provides": [
14
+ "shop screen from the title storefront",
15
+ "several stores as tabs",
16
+ "offer carousel and grids with countdowns",
17
+ "free daily gift offer",
18
+ "purchase confirmation popup",
19
+ "reward animation with icons flying to balances",
20
+ "shop badge for free offers",
21
+ "demo store settings"
22
+ ],
23
+ "tags": [
24
+ "shop",
25
+ "store",
26
+ "storefront",
27
+ "purchase",
28
+ "offers",
29
+ "ui",
30
+ "screen",
31
+ "economy"
32
+ ],
33
+ "author": {
34
+ "name": "iDos Games",
35
+ "url": "https://idosgames.com"
36
+ },
37
+ "version": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@idosgames/core": "0.19.0",
41
+ "@idosgames/module-sdk": "0.6.0",
42
+ "@idosgames/react": "0.3.0",
43
+ "react": "19.2.7"
44
+ },
45
+ "contentHash": "355f6a164da0c42e75ba35eef6f480417e676dbfc5985cef8cccc6127beebde5",
46
+ "files": [
47
+ {
48
+ "path": "i18n.ts",
49
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n});\n"
50
+ },
51
+ {
52
+ "path": "index.ts",
53
+ "content": "// @idosgames/mod-store — Shop screen for any game: the title's storefront with several stores as tabs, a carousel and grids of offers, purchase confirmation and the reward animation.\n\nexport { storeModule } from \"./module\";\n"
54
+ },
55
+ {
56
+ "path": "model.ts",
57
+ "content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
58
+ },
59
+ {
60
+ "path": "module.ts",
61
+ "content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function StoreBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n void client.store.getStorefront().then(update);\n const off = client.on(\"user:anyUpdated\", update);\n // A free gift comes back with the next day: look again every few minutes.\n const id = setInterval(\n () => void client.store.getStorefront().then(update),\n 5 * 60_000,\n );\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
62
+ },
63
+ {
64
+ "path": "StoreScreen.tsx",
65
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n canAfford,\n costLines,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n earliestRefresh,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n ...panel,\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n background:\n free && !disabled\n ? `linear-gradient(180deg, #3d5bd6 0%, ${v.panelDeep} 100%)`\n : panel.background,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone={free ? \"green\" : \"gold\"}\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\" }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
66
+ },
67
+ {
68
+ "path": "module.meta.json",
69
+ "content": "{\n \"id\": \"store\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,68 @@
1
+ {
2
+ "id": "tutorials",
3
+ "meta": {
4
+ "name": "Tutorials",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.",
12
+ "description": "A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.",
13
+ "provides": [
14
+ "tutorial overlay with a highlighted element",
15
+ "hint bubble with next and skip",
16
+ "any element as an anchor via data-tutorial-anchor",
17
+ "tutorial completion reward",
18
+ "tutorials list with replay",
19
+ "demo welcome tour"
20
+ ],
21
+ "tags": [
22
+ "tutorial",
23
+ "onboarding",
24
+ "ftue",
25
+ "coach-marks",
26
+ "hints",
27
+ "ui"
28
+ ],
29
+ "author": {
30
+ "name": "iDos Games",
31
+ "url": "https://idosgames.com"
32
+ },
33
+ "version": "0.1.0"
34
+ },
35
+ "dependencies": {
36
+ "@idosgames/core": "0.19.0",
37
+ "@idosgames/module-sdk": "0.6.0",
38
+ "@idosgames/react": "0.3.0",
39
+ "react": "19.2.7"
40
+ },
41
+ "contentHash": "e208b492977fc69db40d6b2105869911937437a5fd0b7aa15c90eedc0d88efff",
42
+ "files": [
43
+ {
44
+ "path": "i18n.ts",
45
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Tutorials\",\n next: \"Next\",\n skip: \"Skip\",\n waiting: \"Do it to go on\",\n done: \"Tutorial complete!\",\n claim: \"Claim\",\n noTutorials: \"No tutorials in this game.\",\n replay: \"Replay\",\n completed: \"Completed\",\n inProgress: \"In progress\",\n notStarted: \"Not started\",\n skipped: \"Skipped\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Обучение\",\n next: \"Далее\",\n skip: \"Пропустить\",\n waiting: \"Сделайте это, чтобы продолжить\",\n done: \"Обучение пройдено!\",\n claim: \"Забрать\",\n noTutorials: \"Обучения в этой игре нет.\",\n replay: \"Пройти заново\",\n completed: \"Пройдено\",\n inProgress: \"Идёт\",\n notStarted: \"Не начато\",\n skipped: \"Пропущено\",\n});\n"
46
+ },
47
+ {
48
+ "path": "index.ts",
49
+ "content": "// @idosgames/mod-tutorials — Onboarding for any game: hint bubbles next to marked elements, step by step, with a reward and a replay list.\n\nexport { tutorialsModule } from \"./module\";\n"
50
+ },
51
+ {
52
+ "path": "model.ts",
53
+ "content": "// Pure helpers of the tutorial overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry): which\n// flow to show, what the step says, whether the player may tap \"Next\", and where the hint bubble\n// goes around the highlighted element. The server owns the order, the completion and the reward.\n// Covered by model.test.ts.\n\nexport interface StepView {\n Identity?: {\n Title?: string | null;\n TitleKey?: string | null;\n Body?: string | null;\n BodyKey?: string | null;\n AnchorID?: string | null;\n HighlightTarget?: string | null;\n } | null;\n Completion?: { Mode?: string | null } | null;\n}\n\nexport interface FlowView {\n FlowID: string;\n Status?: string | null;\n CurrentStepID?: string | null;\n CurrentStep?: StepView | null;\n TotalSteps?: number | null;\n CompletedSteps?: number | null;\n CanSkip?: boolean | null;\n RewardPending?: boolean | null;\n}\n\n/** The flow to show now: one in progress (with a step), else one whose reward waits. */\nexport function activeFlow(\n flows: readonly FlowView[] | null | undefined,\n): FlowView | null {\n const list = flows ?? [];\n return (\n list.find((f) => f.Status === \"InProgress\" && f.CurrentStep) ??\n list.find((f) => f.RewardPending) ??\n null\n );\n}\n\n/** Only a ClientAck step is closed by the player; SystemEvent / Composite wait for the game. */\nexport function canTapNext(step: StepView | null | undefined): boolean {\n return (step?.Completion?.Mode ?? \"ClientAck\") === \"ClientAck\";\n}\n\n/** The element to highlight: HighlightTarget, else the anchor. */\nexport function anchorOf(step: StepView | null | undefined): string | null {\n return step?.Identity?.HighlightTarget || step?.Identity?.AnchorID || null;\n}\n\n/** Title and body through the title's localization when a key is set. */\nexport function stepText(\n step: StepView | null | undefined,\n localize: (text: string) => string,\n): { title: string; body: string } {\n const id = step?.Identity;\n return {\n title: id?.TitleKey ? localize(id.TitleKey) : (id?.Title ?? \"\"),\n body: id?.BodyKey ? localize(id.BodyKey) : (id?.Body ?? \"\"),\n };\n}\n\nexport interface Rect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\n/**\n * Where the hint bubble goes: below the element when there is room, above otherwise; horizontally\n * centred on it and kept inside the screen with a margin. No element → the middle of the screen.\n */\nexport function bubblePlacement(\n target: Rect | null,\n viewport: { width: number; height: number },\n bubble: { width: number; height: number },\n margin = 12,\n): { left: number; top: number; above: boolean } {\n const width = Math.min(bubble.width, viewport.width - margin * 2);\n if (!target)\n return {\n left: (viewport.width - width) / 2,\n top: Math.max(margin, (viewport.height - bubble.height) / 2),\n above: false,\n };\n const below = target.top + target.height + margin;\n const above =\n below + bubble.height > viewport.height - margin &&\n target.top - margin - bubble.height >= margin;\n const top = above\n ? target.top - margin - bubble.height\n : Math.min(below, viewport.height - margin - bubble.height);\n const centre = target.left + target.width / 2 - width / 2;\n return {\n left: Math.min(Math.max(margin, centre), viewport.width - margin - width),\n top: Math.max(margin, top),\n above,\n };\n}\n\n/** \"2 / 5\" — the step being shown, counted from 1. */\nexport function stepCounter(flow: FlowView): string {\n const total = Number(flow.TotalSteps ?? 0);\n const done = Number(flow.CompletedSteps ?? 0);\n return total > 0 ? `${Math.min(total, done + 1)} / ${total}` : \"\";\n}\n"
54
+ },
55
+ {
56
+ "path": "module.ts",
57
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createTutorialStore,\n makeTutorialOverlay,\n makeTutorialsScreen,\n} from \"./TutorialOverlay\";\nimport { t } from \"./i18n\";\n\n// Onboarding for any game: an always-on overlay shows the current step next to the element marked\n// data-tutorial-anchor=\"{AnchorID}\"; the \"Tutorials\" feature lists the flows and replays them.\nexport const tutorialsModule: Module = defineModule({\n id: \"tutorials\",\n meta: { name: \"Tutorials\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createTutorialStore();\n ctx.registerPanel({\n id: \"overlay\",\n slot: \"modal\",\n activeOnly: false,\n component: makeTutorialOverlay(store),\n });\n ctx.features.register({\n id: \"tutorials\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"profile\",\n order: 110,\n available: false,\n Screen: makeTutorialsScreen(store),\n });\n // The list shows once the player has a flow to see again — from the state the overlay loads at\n // start anyway. No call of its own: the startup burst of every module's first read already\n // brushes the engine's per-player rate limit.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"tutorials\",\n (store.get()?.length ?? 0) > 0,\n \"no tutorial flow for this player (Tutorial section empty, or no flow started yet)\",\n ),\n );\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"tutorials\",\n running:\n store\n .get()\n ?.filter((f) => f.Status === \"InProgress\")\n .map((f) => f.FlowID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
58
+ },
59
+ {
60
+ "path": "TutorialOverlay.tsx",
61
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Spinner,\n bundleLines,\n errorText,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n activeFlow,\n anchorOf,\n bubblePlacement,\n canTapNext,\n stepCounter,\n stepText,\n type FlowView,\n type Rect,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The tutorial overlay (Unity Runtime/UI/Tutorial): the current step of the running flow as a hint\n// bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module,\n// like Unity's TutorialAnchorRegistry. It waits while a popup is open, \"Next\" only on steps the\n// player closes, and the reward when the flow ends. The \"Tutorials\" feature replays a flow.\n\n/** Tutorial state shared by the overlay and the replay screen. */\nexport function createTutorialStore() {\n let flows: FlowView[] | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => flows,\n set(next: FlowView[] | null) {\n flows = next;\n for (const l of listeners) l();\n },\n subscribe(l: () => void) {\n listeners.add(l);\n return () => {\n listeners.delete(l);\n };\n },\n };\n}\nexport type TutorialStore = ReturnType<typeof createTutorialStore>;\n\nconst BUBBLE = { width: 320, height: 150 };\n\n/** What an applied operation granted: the standard part plus premium bundles. */\nfunction operationLines(op: unknown) {\n const grant = (\n op as\n | {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Grant;\n return [\n ...bundleLines(grant?.Standard as never),\n ...(grant?.PremiumTiers ?? []).flatMap((t) =>\n bundleLines(t?.Resources as never),\n ),\n ];\n}\n\nfunction modalOpen(): boolean {\n return document.querySelector(\"[data-idos-ui-layer] [role=dialog]\") !== null;\n}\n\nexport function makeTutorialOverlay(store: TutorialStore) {\n return function TutorialOverlay(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const [flows, setFlows] = useState<FlowView[] | null>(store.get());\n const [rect, setRect] = useState<Rect | null>(null);\n const [blocked, setBlocked] = useState(true);\n const [busy, setBusy] = useState(false);\n const shown = useRef(new Set<string>());\n\n const load = useCallback(async () => {\n const res = await client.tutorial.getUserTutorialState();\n store.set(res.ok ? ((res.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n\n useEffect(() => store.subscribe(() => setFlows(store.get())), []);\n useEffect(() => {\n void load();\n // A SystemEvent step closes on a game action; the progress arrives with that action's reply.\n const off = client.on(\"tutorial:systemProgress\", () => void load());\n return () => off();\n }, [client, load]);\n\n const flow = activeFlow(flows);\n const step = flow?.Status === \"InProgress\" ? flow.CurrentStep : null;\n const anchor = anchorOf(step);\n\n // Follow the anchor (it moves with layout, scroll and screen changes) and step aside for popups.\n useEffect(() => {\n if (!flow) return;\n const tick = () => {\n setBlocked(modalOpen());\n const el = anchor\n ? document.querySelector(\n `[data-tutorial-anchor=\"${CSS.escape(anchor)}\"]`,\n )\n : null;\n const r = el?.getBoundingClientRect();\n setRect(\n r && r.width > 0 && r.height > 0\n ? { left: r.left, top: r.top, width: r.width, height: r.height }\n : null,\n );\n };\n tick();\n const id = setInterval(tick, 250);\n return () => clearInterval(id);\n }, [flow?.FlowID, flow?.CurrentStepID, anchor]);\n\n // The funnel measures time on step from this report — once per step shown.\n useEffect(() => {\n if (!flow || !flow.CurrentStepID || blocked) return;\n const key = `${flow.FlowID}:${flow.CurrentStepID}`;\n if (shown.current.has(key)) return;\n shown.current.add(key);\n kit.play(\"notify\");\n void client.tutorial.reportStepShown(flow.FlowID, flow.CurrentStepID);\n }, [flow?.FlowID, flow?.CurrentStepID, blocked]);\n\n if (!flow || blocked) return null;\n\n const act = async (\n fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,\n ) => {\n setBusy(true);\n const res = await fn();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error ?? \"\"), \"error\");\n await load();\n };\n\n const text = step\n ? stepText(step, (k) => catalog.localize(k))\n : { title: t(\"done\"), body: \"\" };\n const place = bubblePlacement(\n step ? rect : null,\n { width: window.innerWidth, height: window.innerHeight },\n BUBBLE,\n );\n const pad = 6;\n\n return (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 45,\n pointerEvents: \"none\",\n }}\n data-idos-tutorial\n >\n {step && rect ? (\n <div\n className=\"idos-pulse\"\n style={{\n position: \"fixed\",\n left: rect.left - pad,\n top: rect.top - pad,\n width: rect.width + pad * 2,\n height: rect.height + pad * 2,\n borderRadius: 14,\n border: `3px solid ${v.gold}`,\n boxShadow: \"0 0 0 9999px rgba(0,0,0,.55)\",\n transition: \"all .25s ease\",\n }}\n />\n ) : (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n background: \"rgba(0,0,0,.55)\",\n }}\n />\n )}\n <div\n style={{\n ...panel,\n position: \"fixed\",\n left: place.left,\n top: place.top,\n width: Math.min(BUBBLE.width, window.innerWidth - 24),\n padding: 14,\n display: \"grid\",\n gap: 8,\n pointerEvents: \"auto\",\n animation: `idos-pop-in ${v.slow} ease both`,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Icon glyph={step ? \"bulb\" : \"trophy\"} size={26} />\n <div style={{ flex: 1, ...outlined, fontSize: 16 }}>\n {text.title}\n </div>\n {step ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {stepCounter(flow)}\n </span>\n ) : null}\n </div>\n {text.body ? (\n <div style={{ fontSize: 14, fontWeight: 600 }}>{text.body}</div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"flex-end\",\n alignItems: \"center\",\n }}\n >\n {step && flow.CanSkip ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy}\n onClick={() =>\n void act(() => client.tutorial.skipFlow(flow.FlowID))\n }\n >\n {t(\"skip\")}\n </Button>\n ) : null}\n {step ? (\n canTapNext(step) ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() =>\n void act(() =>\n client.tutorial.completeStep(\n flow.FlowID,\n flow.CurrentStepID!,\n ),\n )\n }\n data-tutorial-anchor=\"tutorial:next\"\n >\n {t(\"next\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n )\n ) : (\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() =>\n void act(async () => {\n const res = await client.tutorial.claimFlowReward(\n flow.FlowID,\n );\n // The payout sits in `Granted` (an operation), not where the generic reader looks.\n if (res.ok) celebrate(operationLines(res.data.Granted));\n return res;\n })\n }\n >\n {t(\"claim\")}\n </Button>\n )}\n </div>\n </div>\n </div>\n );\n };\n}\n\ntype FlowDef = {\n Identity?: { DisplayName?: string | null; SortOrder?: number | null } | null;\n Policy?: { RestartPolicy?: string | null } | null;\n};\n\n/** \"Tutorials\": every flow of the title with its status; replay where the title allows it. */\nexport function makeTutorialsScreen(store: TutorialStore) {\n return function TutorialsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const stagger = useStagger();\n const [defs, setDefs] = useState<Record<string, FlowDef | null> | null>(\n null,\n );\n const [flows, setFlows] = useState<FlowView[]>([]);\n const [busy, setBusy] = useState<string | null>(null);\n const load = useCallback(async () => {\n // Listing must not start anything — autoStart off.\n const [d, s] = await Promise.all([\n client.tutorial.getTutorialDefinitions(),\n client.tutorial.getUserTutorialState(false),\n ]);\n setDefs(\n d.ok ? ((d.data.Flows ?? {}) as Record<string, FlowDef | null>) : {},\n );\n setFlows(s.ok ? ((s.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const ids = Object.keys(defs).sort(\n (a, b) =>\n Number(defs[a]?.Identity?.SortOrder ?? 0) -\n Number(defs[b]?.Identity?.SortOrder ?? 0),\n );\n if (ids.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noTutorials\")} />;\n const replay = async (id: string) => {\n setBusy(id);\n const reset = await client.tutorial.resetFlow(id);\n const started = reset.ok ? await client.tutorial.startFlow(id) : reset;\n setBusy(null);\n if (!started.ok) return toast(errorText(started.error), \"error\");\n const s = await client.tutorial.getUserTutorialState(false);\n if (s.ok) store.set((s.data.Flows ?? []) as FlowView[]);\n await load();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {ids.map((id, i) => {\n const view = flows.find((f) => f.FlowID === id);\n const status = view?.Status ?? \"NotStarted\";\n const label =\n status === \"Completed\"\n ? t(\"completed\")\n : status === \"InProgress\"\n ? t(\"inProgress\")\n : status === \"Skipped\"\n ? t(\"skipped\")\n : t(\"notStarted\");\n const canReplay =\n defs[id]?.Policy?.RestartPolicy === \"OnRequest\" &&\n (status === \"Completed\" || status === \"Skipped\");\n return (\n <div\n key={id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"bulb\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(defs[id]?.Identity?.DisplayName ?? id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </div>\n </div>\n {canReplay ? (\n <Button\n size=\"sm\"\n tone=\"blue\"\n busy={busy === id}\n onClick={() => void replay(id)}\n >\n {t(\"replay\")}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n };\n}\n"
62
+ },
63
+ {
64
+ "path": "module.meta.json",
65
+ "content": "{\n \"id\": \"tutorials\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
66
+ }
67
+ ]
68
+ }