@idosgames/mcp 0.1.35 → 0.1.36

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 (37) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +124 -120
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +37 -37
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +15 -15
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +8 -8
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +3 -3
  19. package/registry/modules/leaderboard.json +12 -12
  20. package/registry/modules/lootboxes.json +8 -8
  21. package/registry/modules/mailbox.json +3 -3
  22. package/registry/modules/marketplace.json +3 -3
  23. package/registry/modules/mini-game.json +7 -7
  24. package/registry/modules/multiplayer.json +28 -16
  25. package/registry/modules/premium.json +3 -3
  26. package/registry/modules/profile.json +3 -3
  27. package/registry/modules/pvp.json +3 -3
  28. package/registry/modules/quests.json +10 -10
  29. package/registry/modules/rewards.json +3 -3
  30. package/registry/modules/season.json +3 -3
  31. package/registry/modules/store.json +3 -3
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +13 -13
  34. package/registry/modules/workshop.json +3 -3
  35. package/registry/skills/idosgames-module-contract.json +1 -1
  36. package/registry/skills/leaderboard-system.json +2 -2
  37. package/registry/skills/multiplayer-realtime.json +1 -1
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.4"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.6",
38
- "@idosgames/module-sdk": "0.11.0",
39
- "@idosgames/react": "0.5.1",
37
+ "@idosgames/core": "0.22.0",
38
+ "@idosgames/module-sdk": "0.12.0",
39
+ "@idosgames/react": "0.5.2",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "374cc0e7260f5286c9195509a1879fd6b58cabcec3411c48fe5611abf696fe00",
@@ -33,9 +33,9 @@
33
33
  "version": "0.1.1"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.21.6",
37
- "@idosgames/module-sdk": "0.11.0",
38
- "@idosgames/react": "0.5.1",
36
+ "@idosgames/core": "0.22.0",
37
+ "@idosgames/module-sdk": "0.12.0",
38
+ "@idosgames/react": "0.5.2",
39
39
  "react": "19.2.7"
40
40
  },
41
41
  "contentHash": "bf7c4ec82041941ef2251c1fb5d4b00633ca81b80321020ee03dccd395f98d51",
@@ -31,19 +31,19 @@
31
31
  "name": "iDos Games",
32
32
  "url": "https://idosgames.com"
33
33
  },
34
- "version": "0.1.1"
34
+ "version": "0.1.2"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.6",
38
- "@idosgames/module-sdk": "0.11.0",
39
- "@idosgames/react": "0.5.1",
37
+ "@idosgames/core": "0.22.0",
38
+ "@idosgames/module-sdk": "0.12.0",
39
+ "@idosgames/react": "0.5.2",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "a0ca5f4e1facf2ae8c7728ac2cd24d2b52687ec1ebef06115d0200e8ff4cb20f",
42
+ "contentHash": "0df78530268b83685205589e187335a16c93661c44ce61f2388e86b1078d70c0",
43
43
  "files": [
44
44
  {
45
45
  "path": "i18n.ts",
46
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Quests\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n quests: \"Quests\",\n permanent: \"Achievements\",\n noQuests: \"No quests. Add them in LiveOps → Quests.\",\n questsReset: \"New quests in\",\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 claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n quests: \"Задания\",\n permanent: \"Достижения\",\n noQuests: \"Заданий нет. Добавьте их в LiveOps → Quests.\",\n questsReset: \"Новые задания через\",\n objectives: \"Цели\",\n reward: \"Награда\",\n points: \"Очки заданий\",\n done: \"Выполнено\",\n});\n"
46
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Quests\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n quests: \"Quests\",\n permanent: \"Achievements\",\n noQuests: \"No quests. Add them in LiveOps → Quests.\",\n questsReset: \"New quests in\",\n objectives: \"Goals\",\n reward: \"Reward\",\n points: \"Quest points\",\n done: \"Done\",\n complete: \"Complete\",\n questDetail: \"Quest Detail\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Задания\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n quests: \"Задания\",\n permanent: \"Достижения\",\n noQuests: \"Заданий нет. Добавьте их в LiveOps → Quests.\",\n questsReset: \"Новые задания через\",\n objectives: \"Цели\",\n reward: \"Награда\",\n points: \"Очки заданий\",\n done: \"Выполнено\",\n complete: \"Готово\",\n questDetail: \"Задание\",\n});\n"
47
47
  },
48
48
  {
49
49
  "path": "index.ts",
@@ -51,19 +51,19 @@
51
51
  },
52
52
  {
53
53
  "path": "model.ts",
54
- "content": "// Pure helpers of the quests screen: quest rows with the player's progress, the cycle tabs and the\n// quest points track. No React, no client calls — covered by model.test.ts against the module's own\n// demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { grantLines, mergeLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\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// ── 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 quests screen: finished quests and track rewards waiting. */\nexport function questsBadge(rows: QuestRow[], trackReady: number): number {\n return rows.filter((r) => r.status === \"Completed\").length + trackReady;\n}\n\n/** Whether the title has quests — until it does, the lobby hides the tab. */\n/**\n * When a cycle's quests reset, or null when they never do. A cycle without a reset (schedule\n * AlwaysOn — a guide chain, permanent tasks) comes from the engine with CycleEndUtc =\n * DateTime.MaxValue (9999-12-31): the engine keeps it as the window's key, and the screen counted\n * down to it — \"New quests in 2912168d\".\n */\nexport function cycleResetAt(\n state: QuestStateView | null | undefined,\n cycleID: string,\n): string | null {\n const end = cycleID ? state?.Cycles?.[cycleID]?.CycleEndUtc : null;\n if (!end) return null;\n const year = new Date(end).getUTCFullYear();\n return Number.isFinite(year) && year < NEVER_YEAR ? end : null;\n}\n\n/** From this year on a date is the engine's \"never\" (DateTime.MaxValue), not a real moment. */\nconst NEVER_YEAR = 9000;\n\nexport function hasTitleData(quest: QuestSection | undefined): boolean {\n return Object.keys(quest?.Quests ?? {}).length > 0;\n}\n"
54
+ "content": "// Pure helpers of the quests screen: quest rows with the player's progress, the cycle tabs and the\n// quest points track. No React, no client calls — covered by model.test.ts against the module's own\n// demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n mergeLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\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// ── 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 /** The VIP part (`Rewards.PremiumTiers`), empty when the milestone has none. */\n vip: ResourceLine[];\n vipPremiumID: string | null;\n /**\n * none — no VIP part; locked — no pass; ready — the pass is held and the milestone is reached and\n * not claimed (the claim gives both); granted — claimed with the pass. The quest track carries no\n * ledger of VIP parts, so a pass bought AFTER the claim is not shown as a top-up here.\n */\n vipState: \"none\" | \"locked\" | \"ready\" | \"granted\";\n}\n\n/** The player's premium passes, as the client caches them (`state.Premium`). */\nexport interface PremiumView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null\n > | null;\n MaxActiveTier?: number | null;\n}\n\ntype PremiumTier = {\n RequiredPremiumID?: string | null;\n MinPremiumTier?: number | null;\n Resources?: unknown;\n};\n\n/** Does the player hold the pass a premium bundle asks for (a named pass, or any pass of a tier)? */\nexport function hasPremium(\n premium: PremiumView | null | undefined,\n premiumID: string | null | undefined,\n minTier: number | null | undefined,\n nowMs: number,\n): boolean {\n if (premiumID) {\n const until = premium?.Subscriptions?.[premiumID]?.ExpirationDate;\n return !!until && new Date(until).getTime() > nowMs;\n }\n return (\n Number(premium?.MaxActiveTier ?? 0) >= Math.max(1, Number(minTier ?? 1))\n );\n}\n\n/** 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 premium?: PremiumView | null,\n nowMs: number = Date.now(),\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 const state: TrackMilestone[\"state\"] = claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\";\n const tiers = ((\n m.Rewards as { PremiumTiers?: PremiumTier[] | null } | null\n )?.PremiumTiers ?? []) as PremiumTier[];\n const held = tiers.some((t) =>\n hasPremium(premium, t.RequiredPremiumID, t.MinPremiumTier, nowMs),\n );\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state,\n vip: tiers.flatMap((t) => bundleLines(t.Resources as never)),\n vipPremiumID:\n tiers.find((t) => t.RequiredPremiumID)?.RequiredPremiumID ?? null,\n vipState:\n tiers.length === 0\n ? \"none\"\n : !held\n ? \"locked\"\n : state === \"claimed\"\n ? \"granted\"\n : state === \"ready\"\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the quests screen: finished quests and track rewards waiting. */\nexport function questsBadge(rows: QuestRow[], trackReady: number): number {\n return rows.filter((r) => r.status === \"Completed\").length + trackReady;\n}\n\n/** Whether the title has quests — until it does, the lobby hides the tab. */\n/**\n * When a cycle's quests reset, or null when they never do. A cycle without a reset (schedule\n * AlwaysOn — a guide chain, permanent tasks) comes from the engine with CycleEndUtc =\n * DateTime.MaxValue (9999-12-31): the engine keeps it as the window's key, and the screen counted\n * down to it — \"New quests in 2912168d\".\n */\nexport function cycleResetAt(\n state: QuestStateView | null | undefined,\n cycleID: string,\n): string | null {\n const end = cycleID ? state?.Cycles?.[cycleID]?.CycleEndUtc : null;\n if (!end) return null;\n const year = new Date(end).getUTCFullYear();\n return Number.isFinite(year) && year < NEVER_YEAR ? end : null;\n}\n\n/** From this year on a date is the engine's \"never\" (DateTime.MaxValue), not a real moment. */\nconst NEVER_YEAR = 9000;\n\nexport function hasTitleData(quest: QuestSection | undefined): boolean {\n return Object.keys(quest?.Quests ?? {}).length > 0;\n}\n"
55
55
  },
56
56
  {
57
57
  "path": "module.ts",
58
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { QuestsScreen, watchQuests } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: () => t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n Screen: QuestsScreen,\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled) watchQuests(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeQuestsScreen, watchQuests } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: () => t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n Screen: makeQuestsScreen(ctx.features),\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled) watchQuests(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
59
59
  },
60
60
  {
61
61
  "path": "QuestsScreen.tsx",
62
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type { FeatureScreenProps } 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 grantedBy,\n mergeLines,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n cycleResetAt,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests): the title's quests by cycle — daily, weekly, achievements — with\n// the quest points track on top. A quest's goals and reward open in a popup; \"Claim all\" collects\n// every finished quest.\n\nexport function QuestsScreen(_: FeatureScreenProps): ReactNode {\n return <Quests />;\n}\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 = cycleResetAt(questState, current);\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 badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function watchQuests(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n ctx.every(60_000, () => void client.cache.ensureState([\"Quest\"]));\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
62
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n EmptyState,\n Icon,\n U,\n UButton,\n UIconSlider,\n UItemFrame,\n UMilestonesPopup,\n UPopup,\n URow,\n USlider,\n UScreen,\n UTabs,\n UTimer,\n UTopBar,\n configSection,\n countdown,\n errorText,\n grantedBy,\n mergeLines,\n uText,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n cycleResetAt,\n hasPremium,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type PremiumView,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests, the Layer Lab look of Board_Survival): the dark top bar with the\n// \"Complete N/M\" corner, the orange points track with its timer (tap → the milestone rewards window),\n// light list frames — the reward in a navy frame on the left, the name and a green slider, Claim on\n// the right, claimed quests dimmed — and the pill tabs of the cycles at the bottom. A quest opens the\n// Quest Detail popup (goals with their sliders + the reward).\n\nexport function makeQuestsScreen(features: FeatureRegistry) {\n return function QuestsScreen(_: FeatureScreenProps): ReactNode {\n // A locked VIP reward on the track opens the VIP system's offer, when the title sells one.\n const openVip = features.get(\"premium\")?.available\n ? (premiumID: string) => features.open(\"premium\", { premiumID })\n : undefined;\n return <Quests onOpenVip={openVip} />;\n };\n}\n\nconst ORDER = { Completed: 0, Active: 1, Expired: 2, Claimed: 3 } as const;\nconst INK_TEXT = \"#1f2a52\";\n\nfunction Quests({\n onOpenVip,\n}: {\n onOpenVip?: (premiumID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n const [claiming, setClaiming] = useState<string | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = cycleResetAt(questState, current);\n const ready = shown.filter((r) => r.status === \"Completed\");\n const finished = shown.filter((r) => r.status !== \"Active\").length;\n // Unity order: ready to claim first, then in progress, the claimed ones last.\n const sorted = [...shown].sort((a, b) => ORDER[a.status] - ORDER[b.status]);\n\n const claimOne = async (row: QuestRow) => {\n setClaiming(row.questID);\n const res = await client.quest.claimQuestReward(row.questID, row.cycleID);\n setClaiming(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void load();\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <UScreen\n footer={\n tabs.length > 1 ? (\n <UTabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n alert: rows.some(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ),\n }))}\n value={current}\n onChange={setTab}\n />\n ) : undefined\n }\n >\n <UTopBar\n title={t(\"title\")}\n cornerLabel={t(\"complete\")}\n corner={`${finished}/${shown.length}`}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n resetAt={cycleEnd}\n now={now}\n onClaimed={load}\n onOpenVip={onOpenVip}\n />\n ) : null}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 10,\n padding: \"12px 12px 16px\",\n }}\n >\n {ready.length >= 2 ? (\n <UButton\n tone=\"yellow\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}\n <span style={{ display: \"inline-flex\", gap: 2 }}>\n {claimableRewards(ready)\n .slice(0, 3)\n .map((l, i) => (\n <Icon key={i} glyph={catalog.iconOf(l)} size={20} />\n ))}\n </span>\n </UButton>\n ) : null}\n {sorted.map((row, i) => (\n <div\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n style={stagger(i)}\n onClick={() => setDetail(row)}\n >\n <QuestItem\n row={row}\n busy={claiming === row.questID}\n onClaim={() => void claimOne(row)}\n />\n </div>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </UScreen>\n );\n}\n\n/** One QuestItem row: the reward frame · the name + the slider · Claim / ✓ / percent. */\nfunction QuestItem({\n row,\n busy,\n onClaim,\n}: {\n row: QuestRow;\n busy: boolean;\n onClaim: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const first = row.reward[0] ?? null;\n return (\n <URow\n tone={row.status === \"Completed\" ? \"yellow\" : \"light\"}\n dim={row.status === \"Claimed\"}\n style={{ cursor: \"pointer\" }}\n >\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <UItemFrame line={first} size={60} />\n {row.reward.length > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: -6,\n top: -6,\n minWidth: 22,\n height: 22,\n borderRadius: 11,\n background: \"#ff243d\",\n border: `2px solid ${U.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(12),\n }}\n >\n +{row.reward.length - 1}\n </span>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div\n style={{\n color: INK_TEXT,\n fontSize: 16,\n fontWeight: 900,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {catalog.localize(row.name)}\n </div>\n <USlider\n value={Math.min(row.progress, row.target)}\n max={row.target}\n />\n </div>\n <div onClick={(e) => e.stopPropagation()}>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={40} />\n ) : row.status === \"Completed\" ? (\n <UButton tone=\"green\" attract busy={busy} onClick={onClaim}>\n {t(\"claim\")}\n </UButton>\n ) : (\n <span\n style={{\n ...uText(15),\n display: \"inline-grid\",\n placeItems: \"center\",\n minWidth: 54,\n height: 34,\n borderRadius: 11,\n border: `3px solid ${U.ink}`,\n background: \"linear-gradient(180deg,#8d97b3,#5d6a92)\",\n }}\n >\n {Math.floor((row.progress / Math.max(1, row.target)) * 100)}%\n </span>\n )}\n </div>\n </div>\n </URow>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n const heading = (label: string) => (\n <div\n style={{\n ...uText(16),\n background: \"rgba(3,48,122,.55)\",\n borderRadius: 10,\n padding: \"4px 10px\",\n textAlign: \"center\",\n }}\n >\n {label}\n </div>\n );\n return (\n <UPopup tone=\"blue\" title={t(\"questDetail\")} onClose={onClose} width={420}>\n <div style={{ ...uText(20), textAlign: \"center\" }}>\n {catalog.localize(row.name)}\n </div>\n {row.description ? (\n <div style={{ ...uText(14, \"#e3f2ff\"), textAlign: \"center\" }}>\n {catalog.localize(row.description)}\n </div>\n ) : null}\n {heading(t(\"objectives\"))}\n <div style={{ display: \"grid\", gap: 8 }}>\n {row.objectives.map((o) => (\n <URow key={o.id} tone={o.done ? \"yellow\" : \"light\"}>\n <div style={{ display: \"grid\", gap: 6 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n color: INK_TEXT,\n fontWeight: 900,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={22} /> : null}\n </div>\n <USlider value={Math.min(o.progress, o.target)} max={o.target} />\n </div>\n </URow>\n ))}\n </div>\n {heading(t(\"reward\"))}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {row.reward.map((l, i) => (\n <UItemFrame key={i} line={l} size={66} />\n ))}\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...uText(18, \"#8cf05a\"), textAlign: \"center\" }}>\n {t(\"done\")}\n </div>\n ) : null}\n </UPopup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref} style={{ display: \"grid\" }}>\n <UButton\n tone=\"green\"\n size=\"l\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </UButton>\n </div>\n );\n}\n\n/** Slider_Icon01 of the quests window: the cycle's points, its timer; tap → the rewards window. */\nfunction PointsTrack({\n cycleID,\n section,\n track,\n resetAt,\n now,\n onClaimed,\n onOpenVip,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n resetAt: string | null;\n now: number;\n onClaimed: () => Promise<void>;\n onOpenVip?: (premiumID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState(false);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const premium = state?.Premium as PremiumView | undefined;\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n premium,\n );\n const vipID = milestones.find((m) => m.vipPremiumID)?.vipPremiumID ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 0, Date.now()) : false;\n const left = countdown(resetAt, now);\n if (milestones.length === 0)\n return left ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"center\",\n padding: \"10px 0 0\",\n }}\n >\n <UTimer>{left}</UTimer>\n </div>\n ) : null;\n // The slider shows the way to the NEXT milestone (Unity: \"13/50\"); past the last — the last one.\n const next =\n milestones.find((m) => m.required > points) ??\n milestones[milestones.length - 1];\n const target = next?.required ?? 1;\n const readyCount = milestones.filter((m) => m.state === \"ready\").length;\n\n return (\n <div\n style={{\n padding: \"14px 14px 8px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n background: \"rgba(2,32,92,.45)\",\n }}\n >\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph={readyCount > 0 ? \"gift\" : \"chest\"}\n value={Math.min(points, target)}\n max={target}\n onClick={() => setOpen(true)}\n />\n {readyCount > 0 ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: \"#ff243d\",\n border: `2px solid ${U.ink}`,\n animation: \"idos-u-attract 1.2s ease-in-out infinite\",\n }}\n />\n ) : null}\n </div>\n {left ? <UTimer>{left}</UTimer> : null}\n {open ? (\n <UMilestonesPopup\n glyph=\"star\"\n value={points}\n milestones={milestones.map((m) => ({\n id: m.id,\n required: m.required,\n free: m.reward,\n state: m.state,\n premium: m.vip,\n premiumState:\n m.vipState === \"granted\"\n ? \"claimed\"\n : m.vipState === \"ready\"\n ? \"ready\"\n : \"locked\",\n }))}\n premiumOwned={vipHeld}\n onPremiumLock={\n vipID && onOpenVip ? () => onOpenVip(vipID) : undefined\n }\n onClose={() => setOpen(false)}\n onClaim={async (id) => {\n const picked = milestones.find((m) => m.id === id);\n const res = await client.quest.claimMilestoneReward(cycleID, id);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : (picked?.reward ?? []));\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function watchQuests(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly: the cache patches the quest state in place, so an effect keyed on the\n // state object would not re-run after a claim and the badge would stay lit.\n const update = () => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (client.data.user.state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n };\n update();\n // From the login state; the server only when a quest cycle has ended (the central cache's\n // rule for Quest). The check is local, so it runs every minute: a day that turns mid-session\n // brings the new quests (and the badge) without reopening anything.\n ctx.every(60_000, () => void client.cache.ensureState([\"Quest\"]));\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"quests\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Quests for any game: quests by cycle with details, claim all and the quest points track.\",\n \"description\": \"A ready quests screen (Unity Runtime/UI/Quests) for ANY game, built on the @idosgames/react/ui kit and client.quest. The title's quests: cycles as tabs (daily, weekly, achievements) with the timer to the next cycle, progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'quests' feature; its badge counts finished quests. Ships title-seed.json: four daily quests.\",\n \"provides\": [\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for finished quests\",\n \"demo daily quests\"\n ],\n \"tags\": [\n \"quests\",\n \"missions\",\n \"achievements\",\n \"daily\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
66
+ "content": "{\n \"id\": \"quests\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Quests for any game: quests by cycle with details, claim all and the quest points track.\",\n \"description\": \"A ready quests screen (Unity Runtime/UI/Quests) for ANY game, built on the @idosgames/react/ui kit and client.quest. The title's quests: cycles as tabs (daily, weekly, achievements) with the timer to the next cycle, progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'quests' feature; its badge counts finished quests. Ships title-seed.json: four daily quests.\",\n \"provides\": [\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for finished quests\",\n \"demo daily quests\"\n ],\n \"tags\": [\n \"quests\",\n \"missions\",\n \"achievements\",\n \"daily\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
67
67
  }
68
68
  ]
69
69
  }
@@ -31,9 +31,9 @@
31
31
  "version": "0.1.2"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.21.6",
35
- "@idosgames/module-sdk": "0.11.0",
36
- "@idosgames/react": "0.5.1",
34
+ "@idosgames/core": "0.22.0",
35
+ "@idosgames/module-sdk": "0.12.0",
36
+ "@idosgames/react": "0.5.2",
37
37
  "react": "19.2.7"
38
38
  },
39
39
  "contentHash": "70fde2f88bc77f2c1006bea7e4dce11246951aad4166d8986c32c1acc4a80e4c",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.2"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.6",
40
- "@idosgames/module-sdk": "0.11.0",
41
- "@idosgames/react": "0.5.1",
39
+ "@idosgames/core": "0.22.0",
40
+ "@idosgames/module-sdk": "0.12.0",
41
+ "@idosgames/react": "0.5.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "25ac34032e14fd679c08100e3d8c513f13f3a85b3b021f20f628e627a11b2c8a",
@@ -36,9 +36,9 @@
36
36
  "version": "0.1.2"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.21.6",
40
- "@idosgames/module-sdk": "0.11.0",
41
- "@idosgames/react": "0.5.1",
39
+ "@idosgames/core": "0.22.0",
40
+ "@idosgames/module-sdk": "0.12.0",
41
+ "@idosgames/react": "0.5.2",
42
42
  "react": "19.2.7"
43
43
  },
44
44
  "contentHash": "8442dbfd3b218dc5b93e890604811b5fd9df2893ae492a52a0a37b035ed804d1",
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.2"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.6",
36
- "@idosgames/module-sdk": "0.11.0",
37
- "@idosgames/react": "0.5.1",
35
+ "@idosgames/core": "0.22.0",
36
+ "@idosgames/module-sdk": "0.12.0",
37
+ "@idosgames/react": "0.5.2",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "86cdde5c496c220eda1de6c970c871421b61aca9aefb605227075895a8adf5cf",