@idosgames/mcp 0.1.17 → 0.1.18
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.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +14 -6
- package/registry/index.json +731 -33
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/character-ui.json +78 -0
- package/registry/modules/chat-ui.json +74 -0
- package/registry/modules/craft-ui.json +74 -0
- package/registry/modules/deal-offer-ui.json +73 -0
- package/registry/modules/events-ui.json +80 -0
- package/registry/modules/friends-ui.json +73 -0
- package/registry/modules/game-hud.json +15 -10
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory-ui.json +73 -0
- package/registry/modules/leaderboard-ui.json +75 -0
- package/registry/modules/marketplace-ui.json +75 -0
- package/registry/modules/profile-ui.json +74 -0
- package/registry/modules/pvp-ui.json +72 -0
- package/registry/modules/referral-ui.json +73 -0
- package/registry/modules/rewards-ui.json +75 -0
- package/registry/modules/season-ui.json +79 -0
- package/registry/modules/showcase.json +92 -0
- package/registry/modules/store-ui.json +75 -0
- package/registry/modules/tutorial-ui.json +71 -0
- package/registry/modules/voxelcraft.json +75 -30
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "rewards-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Rewards",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.",
|
|
11
|
+
"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.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"daily login rewards calendar",
|
|
14
|
+
"daily bonus popup on entry",
|
|
15
|
+
"quests by cycle with progress",
|
|
16
|
+
"quest details popup",
|
|
17
|
+
"claim all finished quests",
|
|
18
|
+
"quest points track with milestone chests",
|
|
19
|
+
"badge for waiting rewards",
|
|
20
|
+
"demo daily calendar and quests"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"daily-rewards",
|
|
24
|
+
"calendar",
|
|
25
|
+
"quests",
|
|
26
|
+
"missions",
|
|
27
|
+
"achievements",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen",
|
|
30
|
+
"events"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "iDos Games",
|
|
34
|
+
"url": "https://idosgames.com"
|
|
35
|
+
},
|
|
36
|
+
"version": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@idosgames/core": "0.19.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
41
|
+
"@idosgames/react": "0.3.0",
|
|
42
|
+
"react": "19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"contentHash": "d1668715eb73258f9b6e9f3b24159cf369fac3954d1ccf74bab6c3c5fe674944",
|
|
45
|
+
"files": [
|
|
46
|
+
{
|
|
47
|
+
"path": "i18n.ts",
|
|
48
|
+
"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"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "index.ts",
|
|
52
|
+
"content": "// @idosgames/mod-rewards-ui — 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 { rewardsUiModule } from \"./module\";\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "model.test.ts",
|
|
56
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n calendarColumns,\n claimableRewards,\n dailyView,\n lastCardSpan,\n pickCalendar,\n questRows,\n questTabs,\n rewardsBadge,\n trackMilestones,\n type DailyCalendarView,\n type QuestSection,\n type RewardSection,\n} from \"./model\";\n\n// The helpers run against the module's OWN demo settings (title-seed.json, generated by the engine\n// from the fixture it plays through for real). If the seed changes shape, these say which part of\n// the screen would stop drawing it.\n\ndescribe(\"daily calendar\", () => {\n const calendar = pickCalendar(\n seed.Reward as unknown as RewardSection,\n ) as DailyCalendarView;\n const now = new Date(\"2026-09-27T12:00:00Z\");\n\n it(\"a new player can claim day 1\", () => {\n const v = dailyView(calendar, undefined, now);\n expect(v.canClaim).toBe(true);\n expect(v.days.map((d) => d.state)).toEqual([\n \"today\",\n \"locked\",\n \"locked\",\n \"locked\",\n \"locked\",\n \"locked\",\n \"locked\",\n ]);\n });\n\n it(\"after today's claim the next day waits for tomorrow (UTC)\", () => {\n const v = dailyView(\n calendar,\n { CollectedDays: 1, LastClaimAt: \"2026-09-27T08:00:00Z\" },\n now,\n );\n expect(v.canClaim).toBe(false);\n expect(v.days[0]?.state).toBe(\"collected\");\n const tomorrow = dailyView(\n calendar,\n { CollectedDays: 1, LastClaimAt: \"2026-09-26T23:00:00Z\" },\n now,\n );\n expect(tomorrow.days[1]?.state).toBe(\"today\");\n });\n\n it(\"a looping calendar shows the full row right after the last day\", () => {\n const v = dailyView(\n calendar,\n { CollectedDays: 7, LastClaimAt: \"2026-09-27T08:00:00Z\" },\n now,\n );\n expect(v.days.every((d) => d.state === \"collected\")).toBe(true);\n expect(\n dailyView(\n calendar,\n { CollectedDays: 7, LastClaimAt: \"2026-09-26T08:00:00Z\" },\n now,\n ).days[0]?.state,\n ).toBe(\"today\");\n });\n\n it(\"the server's MinValue date means never claimed\", () => {\n expect(\n dailyView(\n calendar,\n { CollectedDays: 0, LastClaimAt: \"0001-01-01T00:00:00Z\" },\n now,\n ).canClaim,\n ).toBe(true);\n });\n\n it(\"the week is one row only on a tablet; the last card closes a short row\", () => {\n expect(calendarColumns(\"phone\", false, 7)).toBe(4);\n expect(calendarColumns(\"tablet\", false, 7)).toBe(7);\n expect(calendarColumns(\"desktop\", false, 7)).toBe(4);\n expect(calendarColumns(\"tablet\", true, 7)).toBe(4);\n expect(lastCardSpan(7, 4)).toBe(2);\n expect(lastCardSpan(7, 7)).toBe(1);\n expect(lastCardSpan(5, 4)).toBe(4);\n });\n});\n\ndescribe(\"quests\", () => {\n const section = seed.Quest as unknown as QuestSection;\n\n it(\"every seeded quest draws with its goals and reward, ready ones first\", () => {\n const rows = questRows(section, {\n Cycles: {\n daily: {\n Quests: {\n daily_shop: {\n Status: \"Completed\",\n Objectives: { buy: { CurrentValue: 1, Completed: true } },\n },\n daily_spend: {\n Status: \"Active\",\n Objectives: { spend: { CurrentValue: 120, Completed: false } },\n },\n },\n },\n },\n });\n expect(rows[0]).toMatchObject({\n questID: \"daily_shop\",\n status: \"Completed\",\n cycleID: \"daily\",\n });\n expect(rows.find((r) => r.questID === \"daily_spend\")).toMatchObject({\n progress: 120,\n target: 200,\n });\n for (const row of rows) {\n expect(row.reward.length).toBeGreaterThan(0);\n expect(row.objectives.length).toBeGreaterThan(0);\n }\n });\n\n it(\"every seeded quest also moves the season, and its reward says by how much\", () => {\n const rows = questRows(section, null);\n expect(rows.length).toBeGreaterThan(0);\n expect(\n rows.every((r) =>\n r.reward.some(\n (l) =>\n l.kind === \"token\" && l.id === \"Season:main\" && l.amount === 10,\n ),\n ),\n ).toBe(true);\n });\n\n it(\"tabs are the cycles that have quests; claim-all previews the finished ones\", () => {\n const rows = questRows(section, {\n Cycles: { daily: { Quests: { daily_shop: { Status: \"Completed\" } } } },\n });\n expect(questTabs(section, rows).map((t) => t.id)).toEqual([\"daily\"]);\n expect(claimableRewards(rows).length).toBeGreaterThan(0);\n expect(rewardsBadge(true, rows, 1)).toBe(3);\n });\n\n it(\"the points track marks what is claimable\", () => {\n const cycle = {\n Milestones: {\n a: { RequiredProgress: 10 },\n b: { RequiredProgress: 30 },\n c: { RequiredProgress: 5 },\n },\n };\n expect(\n trackMilestones(cycle, 12, [\"c\"]).map((m) => [m.id, m.state]),\n ).toEqual([\n [\"c\", \"claimed\"],\n [\"a\", \"ready\"],\n [\"b\", \"locked\"],\n ]);\n expect(trackMilestones(null, 0, [])).toEqual([]);\n });\n});\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "model.ts",
|
|
60
|
+
"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"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "module.ts",
|
|
64
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\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 rewardsUiModule: Module = defineModule({\n id: \"rewards-ui\",\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 Screen: RewardsScreen,\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"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "RewardsScreen.tsx",
|
|
68
|
+
"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"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "module.meta.json",
|
|
72
|
+
"content": "{\n \"id\": \"rewards-ui\",\n \"type\": \"feature\",\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"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "season-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Season",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"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.",
|
|
11
|
+
"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 showcase mini-game grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"season screen with a tab per chain",
|
|
14
|
+
"season timer and status bar",
|
|
15
|
+
"tier ladder with rewards",
|
|
16
|
+
"claim all reached tiers",
|
|
17
|
+
"VIP second prize on tiers",
|
|
18
|
+
"VIP pass purchase popup",
|
|
19
|
+
"season badge",
|
|
20
|
+
"demo seasons"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"season",
|
|
24
|
+
"battle-pass",
|
|
25
|
+
"tiers",
|
|
26
|
+
"progression",
|
|
27
|
+
"live-ops",
|
|
28
|
+
"vip",
|
|
29
|
+
"ui",
|
|
30
|
+
"screen"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "iDos Games",
|
|
34
|
+
"url": "https://idosgames.com"
|
|
35
|
+
},
|
|
36
|
+
"version": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@idosgames/core": "0.19.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
41
|
+
"@idosgames/react": "0.3.0",
|
|
42
|
+
"react": "19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"contentHash": "81f29463cedaef764861e232b709c16c5688536ccf7fffab23407496311313a8",
|
|
45
|
+
"files": [
|
|
46
|
+
{
|
|
47
|
+
"path": "i18n.ts",
|
|
48
|
+
"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"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "index.ts",
|
|
52
|
+
"content": "// @idosgames/mod-season-ui — Seasons for any game: status tiers with a reward each, the season timer, claim all and a VIP second prize.\n\nexport { seasonUiModule } from \"./module\";\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "model.test.ts",
|
|
56
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n} from \"./model\";\n\n// GetActiveSeason returns the season definition with the player's state; here it is assembled from\n// the seeded chain's first season.\ntype Chain = {\n SeasonChainID: string;\n Seasons: Array<{ SeasonID: string; Tiers: unknown[] }>;\n};\nconst chain = Object.values(\n (seed as unknown as { Season: { Chains: Record<string, Chain> } }).Season\n .Chains,\n)[0]!;\nconst NOW = Date.parse(\"2026-09-28T12:00:00Z\");\nconst vip = {\n Subscriptions: { vip: { ExpirationDate: \"2026-10-28T00:00:00Z\" } },\n};\nconst info = (currentTier: number, claimed: number[] = []) =>\n ({\n SeasonChainID: chain.SeasonChainID,\n Season: chain.Seasons[0],\n UserState: { CurrentTier: currentTier, ClaimedTierRewards: claimed },\n }) as never;\n\ndescribe(\"season\", () => {\n it(\"reads the seeded ladder: five tiers from 0 status, each with a VIP prize\", () => {\n const rows = tierRows(info(1), null, NOW);\n expect(rows.map((r) => r.required)).toEqual([0, 50, 150, 300, 500]);\n expect(\n rows.every((r) => r.vip.length > 0 && r.vipPremiumID === \"vip\"),\n ).toBe(true);\n expect(rows[0]).toMatchObject({\n state: \"ready\",\n current: true,\n vipHeld: false,\n });\n expect(rows[1]!.state).toBe(\"locked\");\n });\n\n it(\"reached tiers are claimable once; the VIP part follows the pass held now\", () => {\n const rows = tierRows(info(3, [1]), vip, NOW);\n expect(rows.map((r) => r.state)).toEqual([\n \"claimed\",\n \"ready\",\n \"ready\",\n \"locked\",\n \"locked\",\n ]);\n expect(readyTiers(rows)).toEqual([2, 3]);\n expect(rows[1]!.vipHeld).toBe(true);\n });\n\n it(\"status never reads below the reached tier's threshold, and the bar spans tier to tier\", () => {\n expect(seasonTokens(info(3), 0)).toBe(150);\n expect(seasonTokens(info(3), 180)).toBe(180);\n const rows = tierRows(info(3), null, NOW);\n expect(tierProgress(rows, 180)).toMatchObject({ from: 150, goal: 300 });\n expect(tierProgress(rows, 900).next).toBeNull();\n });\n\n it(\"the badge counts tier rewards waiting\", () => {\n expect(seasonBadge([info(2)], null, NOW)).toBe(2);\n expect(seasonBadge([info(2, [1, 2])], null, NOW)).toBe(0);\n });\n});\n\ndescribe(\"claimed lines\", () => {\n it(\"sums the standard part and the VIP bundles of the applied operation\", () => {\n const coins = (n: number) => ({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: n }],\n });\n const data = {\n Resources: {\n Grant: {\n Standard: coins(50),\n PremiumTiers: [{ RequiredPremiumID: \"vip\", Resources: coins(50) }],\n },\n },\n };\n expect(claimedLines(data)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n ]);\n expect(\n claimedLines({ Rewards: { Grant: { Standard: coins(10) } } }),\n ).toEqual([{ kind: \"currency\", id: \"CO\", amount: 10 }]);\n expect(claimedLines({})).toBeNull();\n });\n});\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "model.ts",
|
|
60
|
+
"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"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "module.ts",
|
|
64
|
+
"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 seasonUiModule: Module = defineModule({\n id: \"season-ui\",\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 Screen: makeSeasonScreen(store),\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"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "SeasonScreen.tsx",
|
|
68
|
+
"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"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "VipPopup.tsx",
|
|
72
|
+
"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"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"path": "module.meta.json",
|
|
76
|
+
"content": "{\n \"id\": \"season-ui\",\n \"type\": \"feature\",\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. 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 showcase mini-game 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"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "showcase",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Showcase",
|
|
5
|
+
"type": "game",
|
|
6
|
+
"engine": "dom",
|
|
7
|
+
"genre": "casual"
|
|
8
|
+
},
|
|
9
|
+
"sharedUi": {
|
|
10
|
+
"drawsOwn": [
|
|
11
|
+
"account",
|
|
12
|
+
"menu"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"catalog": {
|
|
16
|
+
"type": "template",
|
|
17
|
+
"summary": "The lobby a new title starts from: balances, the profile and the game's screens (shop, heroes, leaders, rewards, …) as tabs and 'More', plus a tiny mini-game.",
|
|
18
|
+
"description": "The minimal complete app a new title starts from when no other template fits the idea. It is a LOBBY: every screen is another catalog module registered as a feature of the game (ctx.features) — store-ui, inventory-ui, leaderboard-ui, rewards-ui and the other *-ui modules — and the lobby lays out whatever is installed: tabs at the bottom with 'More' on a phone, a centred tab pill on a tablet, a side rail on a desktop, badges on every tab. Its own content is only a 15-second tap mini-game that submits a score to the title's leaderboard and reports rounds to quests. The header shows the balances (reward icons fly into them) and the player's profile. Remove a screen module from src/modules.ts and its tab disappears; add one and it appears. Ships title-seed.json with the currencies and items the screens share; each screen module brings its own section.",
|
|
19
|
+
"provides": [
|
|
20
|
+
"lobby for a game made of screen modules",
|
|
21
|
+
"bottom tabs with More on phones, tab pill on tablets, side rail on desktops",
|
|
22
|
+
"badges on tabs",
|
|
23
|
+
"balance counters that reward icons fly into",
|
|
24
|
+
"player profile button",
|
|
25
|
+
"tap mini-game that submits a leaderboard score",
|
|
26
|
+
"demo currencies and items"
|
|
27
|
+
],
|
|
28
|
+
"tags": [
|
|
29
|
+
"starter",
|
|
30
|
+
"showcase",
|
|
31
|
+
"lobby",
|
|
32
|
+
"menu",
|
|
33
|
+
"tabs",
|
|
34
|
+
"mini-game",
|
|
35
|
+
"casual"
|
|
36
|
+
],
|
|
37
|
+
"author": {
|
|
38
|
+
"name": "iDos Games",
|
|
39
|
+
"url": "https://idosgames.com"
|
|
40
|
+
},
|
|
41
|
+
"version": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@idosgames/core": "0.19.0",
|
|
45
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
46
|
+
"@idosgames/react": "0.3.0",
|
|
47
|
+
"react": "19.2.7"
|
|
48
|
+
},
|
|
49
|
+
"contentHash": "72b9c79dc24f916da62550f6e69e37fc5d72e92a00db853f6f0ec6332b97372d",
|
|
50
|
+
"files": [
|
|
51
|
+
{
|
|
52
|
+
"path": "Account.tsx",
|
|
53
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n Popup,\n outlined,\n useGameLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The player's corner of the lobby header. With a profile screen installed (profile-ui) the button\n// opens it; otherwise a small popup with the player ID (tap to copy) and \"Log out\". The module\n// declares `sharedUi.drawsOwn: [\"account\"]`, so the host does not float its own copy over the tabs.\n\nexport function Account({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n const [copied, setCopied] = useState(false);\n const userID = client.auth.context?.userID ?? null;\n const hasProfile = features.get(\"profile\") !== undefined;\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n const logout = (): void => void client.auth.logout();\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n const onProfile = (): void => {\n kit.play(\"click\");\n if (hasProfile) features.open(\"profile\");\n else setOpen(true);\n };\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"profile\")}\n onClick={onProfile}\n style={chip}\n data-tutorial-anchor=\"lobby:profile\"\n >\n <Icon glyph=\"user\" size={layout === \"phone\" ? 30 : 24} />\n {layout !== \"phone\" && shortID ? (\n <span\n style={{\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 13,\n }}\n >\n {shortID}\n </span>\n ) : null}\n </button>\n {open ? (\n <Popup title={t(\"profile\")} onClose={() => setOpen(false)} width={380}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Icon glyph=\"user\" size={64} />\n {userID ? (\n <>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"playerId\")}\n </div>\n <div\n style={{\n ...outlined,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 14,\n wordBreak: \"break-all\",\n textAlign: \"center\",\n }}\n >\n {userID}\n </div>\n </>\n ) : null}\n </div>\n {userID ? (\n <Button tone=\"blue\" onClick={copy}>\n <Icon glyph={copied ? \"check\" : \"copy\"} size={20} />{\" \"}\n {copied ? t(\"copied\") : t(\"copyId\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={logout}>\n <Icon glyph=\"logout\" size={20} /> {t(\"logout\")}\n </Button>\n </Popup>\n ) : null}\n </>\n );\n}\n\nconst chip: CSSProperties = {\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: 3,\n paddingRight: 10,\n borderRadius: 999,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(8, 10, 40, .6)\",\n boxShadow: \"inset 0 2px 4px rgba(0,0,0,.4)\",\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n};\n"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "i18n.ts",
|
|
57
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the lobby and its mini-game. Names of currencies, items and the rest are the\n// publisher's words (title config) and the features' own labels.\n\nconst EN = {\n play: \"Play\",\n playAgain: \"Play again\",\n tapStars: \"Tap the stars!\",\n timeLeft: \"Time\",\n score: \"Score\",\n yourScore: \"Your score\",\n submitted: \"Submitted to the leaderboard\",\n seasonStatus: \"season status\",\n toTable: \"Leaderboard\",\n best: \"Tap as many stars as you can in 15 seconds. Gold stars are worth 3.\",\n more: \"More\",\n profile: \"Profile\",\n playerId: \"Player ID\",\n copyId: \"Copy ID\",\n copied: \"Copied\",\n logout: \"Log out\",\n noFeatures:\n \"No screens installed. Add screen modules — store-ui, inventory-ui, leaderboard-ui, rewards-ui and others — to src/modules.ts.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n play: \"Играть\",\n playAgain: \"Ещё раз\",\n tapStars: \"Ловите звёзды!\",\n timeLeft: \"Время\",\n score: \"Очки\",\n yourScore: \"Ваш результат\",\n submitted: \"Результат отправлен в таблицу\",\n seasonStatus: \"статуса сезона\",\n toTable: \"Таблица лидеров\",\n best: \"Поймайте как можно больше звёзд за 15 секунд. Золотая — 3 очка.\",\n more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n noFeatures:\n \"Экраны не установлены. Добавьте модули-экраны — store-ui, inventory-ui, leaderboard-ui, rewards-ui и другие — в src/modules.ts.\",\n});\n"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "index.ts",
|
|
61
|
+
"content": "// @idosgames/mod-showcase — the lobby a new title starts from: it lays out the screens of the other\n// installed modules (ctx.features) as tabs and \"More\", plus a tiny mini-game for the leaderboard.\n//\n// Primary export is the module manifest; the pieces are exported too, so a project can recompose\n// them (replace the mini-game with the real game) after copying this into src/modules/.\n\nexport { showcaseModule } from \"./module\";\nexport { makeLobbyPanel, type LobbyOptions } from \"./Lobby\";\nexport { makePlayScreen } from \"./PlayScreen\";\nexport { MiniGame } from \"./MiniGame\";\nexport * from \"./model\";\n"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"path": "Lobby.tsx",
|
|
65
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n ResourceCounter,\n outlined,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport { Account } from \"./Account\";\nimport { MORE, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby: the game's screens are OTHER modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace…); the lobby only lays them out, in three layouts by its width:\n// - phone: balances and the profile on top, the screen, 4 tabs + \"More\" at the bottom (the Unity lobby);\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. Remove a screen module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** false when another module (game-hud) already draws the balances for every mode. */\n drawBalances: boolean;\n /** false when another module (game-hud) already draws the player ID and \"Log out\". */\n drawAccount: boolean;\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const list = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(null);\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {options.drawBalances\n ? balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n features.get(\"store\") ? () => go(\"store\") : undefined\n }\n />\n ))\n : null}\n </div>\n {options.drawAccount ? <Account features={features} /> : null}\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? \"0 3px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n color: active ? v.text : v.textDim,\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.04)\",\n boxShadow: active\n ? \"0 4px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n color: active ? v.text : v.textDim,\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "MiniGame.tsx",
|
|
69
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useSound,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// \"Tap the stars\": a 15-second round that produces a score for the leaderboard. Deliberately tiny —\n// the placeholder a project replaces with its real game, keeping the same contract: play, then hand\n// the score to onFinish (the play screen submits it and reports the round to quests).\n\nconst ROUND_MS = 15_000;\nconst SPAWN_MS = 520;\nconst LIFE_MS = 1_150;\n\ninterface Star {\n id: number;\n x: number;\n y: number;\n golden: boolean;\n bornAt: number;\n}\n\nexport function MiniGame({\n onFinish,\n onCancel,\n height = 380,\n}: {\n onFinish: (score: number) => void;\n onCancel: () => void;\n height?: number;\n}): ReactNode {\n const play = useSound();\n const [stars, setStars] = useState<Star[]>([]);\n const [score, setScore] = useState(0);\n const [leftMs, setLeftMs] = useState(ROUND_MS);\n const [pops, setPops] = useState<\n Array<{ id: number; x: number; y: number; value: number }>\n >([]);\n const scoreRef = useRef(0);\n const nextId = useRef(1);\n const finished = useRef(false);\n // Latest callback without restarting the round when the parent re-renders.\n const finish = useRef(onFinish);\n finish.current = onFinish;\n\n useEffect(() => {\n const started = performance.now();\n const spawn = setInterval(() => {\n const now = performance.now();\n setStars((list) => [\n ...list.filter((s) => now - s.bornAt < LIFE_MS),\n {\n id: nextId.current++,\n x: 8 + Math.random() * 78,\n y: 10 + Math.random() * 72,\n golden: Math.random() < 0.15,\n bornAt: now,\n },\n ]);\n }, SPAWN_MS);\n const clock = setInterval(() => {\n const left = Math.max(0, ROUND_MS - (performance.now() - started));\n setLeftMs(left);\n if (left === 0 && !finished.current) {\n finished.current = true;\n clearInterval(spawn);\n clearInterval(clock);\n finish.current(scoreRef.current);\n }\n }, 100);\n return () => {\n clearInterval(spawn);\n clearInterval(clock);\n };\n }, []);\n\n const hit = (star: Star) => {\n const value = star.golden ? 3 : 1;\n play(star.golden ? \"coin\" : \"countTick\");\n scoreRef.current += value;\n setScore(scoreRef.current);\n setStars((list) => list.filter((s) => s.id !== star.id));\n const popID = nextId.current++;\n setPops((list) => [...list, { id: popID, x: star.x, y: star.y, value }]);\n setTimeout(\n () => setPops((list) => list.filter((p) => p.id !== popID)),\n 600,\n );\n };\n\n return (\n <div\n style={{\n ...panel,\n position: \"relative\",\n height: `min(${height}px, 70vh)`,\n minHeight: 300,\n overflow: \"hidden\",\n background:\n \"radial-gradient(circle at 50% 30%, #3b47b8 0%, #151a52 75%)\",\n touchAction: \"manipulation\",\n userSelect: \"none\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 10,\n left: 12,\n right: 12,\n display: \"flex\",\n justifyContent: \"space-between\",\n zIndex: 2,\n }}\n >\n <span style={{ ...outlined, fontSize: 18 }}>\n {t(\"score\")}: {score}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 18,\n color: leftMs < 4000 ? v.red : v.text,\n }}\n >\n {t(\"timeLeft\")}: {Math.ceil(leftMs / 1000)}\n </span>\n </div>\n <div\n style={{\n position: \"absolute\",\n top: 40,\n left: 0,\n right: 0,\n textAlign: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.textDim,\n opacity: score === 0 ? 1 : 0,\n transition: \"opacity .4s\",\n }}\n >\n {t(\"tapStars\")}\n </div>\n {stars.map((s) => (\n <button\n key={s.id}\n type=\"button\"\n aria-label=\"star\"\n onPointerDown={() => hit(s)}\n style={{\n position: \"absolute\",\n left: `${s.x}%`,\n top: `${s.y}%`,\n width: 56,\n height: 56,\n marginLeft: -28,\n marginTop: -28,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n animation: `idos-star ${v.normal} ease-out`,\n }}\n >\n <Icon\n glyph=\"star\"\n size={s.golden ? 56 : 44}\n color={s.golden ? \"#ffe066\" : \"#fff27a\"}\n />\n </button>\n ))}\n {pops.map((p) => (\n <span\n key={p.id}\n style={{\n position: \"absolute\",\n left: `${p.x}%`,\n top: `${p.y}%`,\n ...outlined,\n fontSize: 22,\n color: v.gold,\n pointerEvents: \"none\",\n animation: \"idos-rise-in .6s ease reverse\",\n }}\n >\n +{p.value}\n </span>\n ))}\n <div\n style={{\n position: \"absolute\",\n bottom: 10,\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={onCancel}>\n ✕\n </Button>\n </div>\n </div>\n );\n}\n"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"path": "model.test.ts",
|
|
73
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport {\n PLAY_ROUND_ACTION,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n splitTabs,\n tabSlots,\n type LobbyFeature,\n} from \"./model\";\n\n// The lobby decides which of the game's features become tabs and which go under \"More\" — by width,\n// with no knowledge of what the features are. The mini-game helpers pick what it plays for.\n\nconst f = (\n id: string,\n primary: boolean,\n order: number,\n group = \"economy\",\n): LobbyFeature => ({ id, label: id, primary, order, group });\n\nconst MAIN = [\n f(\"store\", true, 10),\n f(\"rewards\", true, 40, \"events\"),\n f(\"leaderboard\", true, 30, \"events\"),\n f(\"inventory\", true, 50),\n f(\"character\", true, 20),\n];\nconst SECONDARY = [\n f(\"play\", false, 5, \"events\"),\n f(\"craft\", false, 10),\n f(\"friends\", false, 10, \"social\"),\n f(\"chat\", false, 20, \"social\"),\n];\n\ndescribe(\"lobby tabs\", () => {\n it(\"everything fits → all are tabs, no More\", () => {\n const { tabs, more } = splitTabs(\n [f(\"store\", true, 10), f(\"rewards\", true, 20), f(\"craft\", false, 5)],\n \"phone\",\n );\n expect(tabs.map((x) => x.id)).toEqual([\"store\", \"rewards\", \"craft\"]);\n expect(more).toEqual([]);\n });\n\n it(\"a phone keeps 4 main features + More; the rest goes under More, main ones first\", () => {\n const { tabs, more } = splitTabs([...MAIN, ...SECONDARY], \"phone\");\n expect(tabs.map((x) => x.id)).toEqual([\n \"store\",\n \"character\",\n \"leaderboard\",\n \"rewards\",\n ]);\n expect(more.map((x) => x.id)).toEqual([\n \"inventory\",\n \"craft\",\n \"play\",\n \"friends\",\n \"chat\",\n ]);\n });\n\n it(\"a tablet keeps 6 + More, a desktop lists everything\", () => {\n expect(splitTabs([...MAIN, ...SECONDARY], \"tablet\").tabs).toHaveLength(6);\n const desk = splitTabs([...MAIN, ...SECONDARY], \"desktop\");\n expect(desk.more).toEqual([]);\n expect(desk.tabs).toHaveLength(9);\n expect(tabSlots(\"desktop\")).toBe(Number.POSITIVE_INFINITY);\n });\n\n it(\"no features → no tabs\", () => {\n expect(splitTabs([], \"phone\")).toEqual({ tabs: [], more: [] });\n });\n});\n\ndescribe(\"mini-game\", () => {\n it(\"prefers weekly_score and skips disabled boards\", () => {\n expect(\n pickLeaderboard({\n Definitions: { a: { IsEnabled: true }, weekly_score: {} },\n })?.LeaderboardID,\n ).toBe(\"weekly_score\");\n expect(\n pickLeaderboard({\n Definitions: { b: { IsEnabled: false }, a: { IsEnabled: true } },\n })?.LeaderboardID,\n ).toBe(\"a\");\n // A board the server fills (invites) is never where the mini-game sends its score.\n expect(\n pickLeaderboard({\n Definitions: {\n a_invites: { ScoreSources: [{ SourceType: \"ReferralInvite\" }] },\n b: {},\n },\n })?.LeaderboardID,\n ).toBe(\"b\");\n expect(pickLeaderboard(undefined)).toBeNull();\n });\n\n it(\"reports rounds only when a quest counts them\", () => {\n expect(\n questsCountGames({\n Quests: { q: { Objectives: { o: { MetricID: \"games_played\" } } } },\n }),\n ).toBe(true);\n expect(\n questsCountGames({\n Quests: {\n q: {\n Objectives: {\n o: { MetricID: \"games_played\", Source: \"SystemEvent\" },\n },\n },\n },\n }),\n ).toBe(false);\n expect(questsCountGames({ Quests: {} })).toBe(false);\n });\n});\n\ndescribe(\"a round feeds events and seasons\", () => {\n it(\"finds the events that pay for a round, including chain phases\", () => {\n const round = {\n SourceType: \"CustomAction\",\n Params: { ActionName: PLAY_ROUND_ACTION },\n };\n expect(\n eventsCountingRounds({\n Definitions: {\n festival: { Content: { TokenSources: [round] } },\n shop_only: {\n Content: { TokenSources: [{ SourceType: \"StorePurchase\" }] },\n },\n chain: {\n Events: [\n { Content: { TokenSources: [] } },\n { Content: { TokenSources: [round] } },\n ],\n },\n other_action: {\n Content: {\n TokenSources: [\n {\n SourceType: \"CustomAction\",\n Params: { ActionName: \"watch_ad\" },\n },\n ],\n },\n },\n },\n }),\n ).toEqual([\"festival\", \"chain\"]);\n expect(eventsCountingRounds(undefined)).toEqual([]);\n });\n\n it(\"grants status only to chains open to clients\", () => {\n expect(\n seasonChainsForRounds({\n Chains: {\n main: { GrantTokensAccessMode: \"Both\" },\n pro: { GrantTokensAccessMode: \"ServerOnly\" },\n own: { GrantTokensAccessMode: \"ClientOnly\" },\n fan: {},\n },\n }),\n ).toEqual([\"main\", \"own\"]);\n });\n});\n"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"path": "model.ts",
|
|
77
|
+
"content": "// Pure helpers of the lobby and its mini-game — covered by model.test.ts.\n\nimport {\n sortFeatures,\n type FeatureMenuItem,\n type Layout,\n} from \"@idosgames/react/ui\";\n\n/** Metric the mini-game reports once per finished round. A quest counts it only if it asks for it. */\nexport const GAMES_PLAYED_METRIC = \"games_played\";\n\n/** Leaderboard the mini-game submits to when the title has it; otherwise the first enabled one. */\nexport const PREFERRED_LEADERBOARD = \"weekly_score\";\n\n/** The pseudo-feature \"More\" — the rest of the features, as a grid. */\nexport const MORE = \"__more\";\n\nexport interface LobbyFeature extends FeatureMenuItem {\n primary?: boolean;\n}\n\n/** How many tabs fit next to \"More\" at this width (the desktop rail lists everything). */\nexport function tabSlots(layout: Layout): number {\n return layout === \"phone\"\n ? 5\n : layout === \"tablet\"\n ? 7\n : Number.POSITIVE_INFINITY;\n}\n\n/**\n * Splits the game's features into the lobby's tabs and \"More\": main (primary) features by their\n * order take the tab slots first, secondary ones fill what is left; the rest goes under \"More\".\n * \"More\" appears only when it has something — a game with three screens has three tabs.\n */\nexport function splitTabs<T extends LobbyFeature>(\n features: readonly T[],\n layout: Layout,\n): { tabs: T[]; more: T[] } {\n const primary = [...features]\n .filter((f) => f.primary)\n .sort(\n (a, b) =>\n (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),\n );\n const secondary = sortFeatures(features.filter((f) => !f.primary));\n const ordered = [...primary, ...secondary];\n const slots = tabSlots(layout);\n if (ordered.length <= slots) return { tabs: ordered, more: [] };\n // One slot goes to \"More\" itself; main features claim the rest first.\n const room = Math.max(1, slots - 1);\n return { tabs: ordered.slice(0, room), more: ordered.slice(room) };\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<\n string,\n {\n LeaderboardID?: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n ScoreSources?: unknown[] | null;\n } | null\n > | null;\n}\n\n/**\n * The leaderboard the mini-game plays for: the preferred id if enabled, else the first enabled one.\n * A board fed by its own sources (invites, purchases…) is not a score table — the server fills it\n * and refuses a submitted score, so the mini-game never picks it.\n */\nexport function pickLeaderboard(\n section: LeaderboardSection | undefined,\n): { LeaderboardID: string; DisplayName?: string | null } | null {\n const defs = Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (\n d,\n ): d is {\n LeaderboardID: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n } => d !== null && d.IsEnabled !== false,\n )\n .filter(\n (d) =>\n ((d as { ScoreSources?: unknown[] | null }).ScoreSources ?? [])\n .length === 0,\n );\n return (\n defs.find((d) => d.LeaderboardID === PREFERRED_LEADERBOARD) ??\n defs.sort((a, b) => a.LeaderboardID.localeCompare(b.LeaderboardID))[0] ??\n null\n );\n}\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n { MetricID?: string | null; Source?: string } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Whether some quest counts the mini-game's rounds — only then is the metric worth reporting. */\nexport function questsCountGames(section: QuestSection | undefined): boolean {\n return Object.values(section?.Quests ?? {}).some((q) =>\n Object.values(q?.Objectives ?? {}).some(\n (o) =>\n o?.MetricID === GAMES_PLAYED_METRIC &&\n (o.Source ?? \"ClientApi\") === \"ClientApi\",\n ),\n );\n}\n\n// ── A round feeds the title's events and seasons ─────────────────────────────────────────────\n// Found in the config, so the showcase needs neither events-ui nor season-ui installed.\n\n/** The CustomAction a round of the mini-game reports to timed events. */\nexport const PLAY_ROUND_ACTION = \"play_round\";\n/** Status a round grants to every season chain open to clients. */\nexport const ROUND_STATUS = 10;\n\nexport interface TimedEventSection {\n Definitions?: Record<\n string,\n {\n Content?: EventContentView | null;\n Events?: Array<{ Content?: EventContentView | null } | null> | null;\n } | null\n > | null;\n}\ntype EventContentView = {\n TokenSources?: Array<{\n SourceType?: string | null;\n Params?: Record<string, string> | null;\n } | null> | null;\n};\n\nfunction countsRounds(content: EventContentView | null | undefined): boolean {\n return (content?.TokenSources ?? []).some(\n (s) =>\n s?.SourceType === \"CustomAction\" &&\n s.Params?.ActionName === PLAY_ROUND_ACTION,\n );\n}\n\n/** Events (by id) whose token sources pay for a round — own content or any phase of a chain. */\nexport function eventsCountingRounds(\n section: TimedEventSection | undefined,\n): string[] {\n return Object.entries(section?.Definitions ?? {})\n .filter(\n ([, d]) =>\n d &&\n (countsRounds(d.Content) ||\n (d.Events ?? []).some((p) => countsRounds(p?.Content))),\n )\n .map(([id]) => id);\n}\n\nexport interface SeasonSection {\n Chains?: Record<\n string,\n { GrantTokensAccessMode?: string | null } | null\n > | null;\n}\n\n/**\n * Season chains the client may grant status to. Only an explicit \"Both\" / \"ClientOnly\" opens a\n * chain: the engine's default is ServerOnly, so a missing field means \"server only\" too.\n */\nexport function seasonChainsForRounds(\n section: SeasonSection | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(\n ([, c]) =>\n c?.GrantTokensAccessMode === \"Both\" ||\n c?.GrantTokensAccessMode === \"ClientOnly\",\n )\n .map(([id]) => id);\n}\n"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"path": "module.ts",
|
|
81
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeLobbyPanel } from \"./Lobby\";\nimport { makePlayScreen } from \"./PlayScreen\";\nimport { t } from \"./i18n\";\n\n// The Showcase template — the lobby a new title starts from when no other template fits the idea.\n// It draws no screen of its own besides a tiny mini-game: the shop, heroes, leaders, rewards and the\n// rest are screen MODULES (store-ui, character-ui, leaderboard-ui, rewards-ui…) registered as\n// features, and the lobby lays out whatever is installed. Its demo settings are the currencies and\n// items the screens share (title-seed.json); each screen module brings its own section.\nexport const showcaseModule: Module = defineModule({\n id: \"showcase\",\n meta: {\n name: \"Showcase\",\n type: \"game\",\n genre: \"casual\",\n engine: \"dom\",\n },\n // The player ID / \"Log out\" and the menu of features are this mode's own header and tabs: the\n // host's floating copies would cover the tab bar. With a shared HUD installed (game-hud) the HUD\n // draws them instead — shouldDraw is false then.\n sharedUi: { drawsOwn: [\"account\", \"menu\"] },\n setup(ctx) {\n ctx.registerRoute({\n id: \"showcase\",\n label: t(\"play\"),\n icon: \"⭐\",\n order: 10,\n });\n ctx.features.register({\n id: \"play\",\n label: t(\"play\"),\n icon: \"star\",\n group: \"events\",\n order: 5,\n Screen: makePlayScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"lobby\",\n slot: \"overlay\",\n component: makeLobbyPanel({\n features: ctx.features,\n drawBalances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n drawAccount: ctx.sharedUi.shouldDraw(\"account\"),\n }),\n });\n\n // The lobby is ordinary React in the DOM: an agent reads it as a tree and presses the buttons\n // like a person. Only what is not on screen goes here.\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n features: ctx.features\n .list()\n .map((f) => ({\n id: f.id,\n primary: f.primary === true,\n badge: f.badge,\n module: f.moduleId,\n })),\n drawsBalances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n drawsAccount: ctx.sharedUi.shouldDraw(\"account\"),\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"path": "PlayScreen.tsx",
|
|
85
|
+
"content": "import { useCallback, useState, type ReactNode } from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The showcase's own game — the one thing the lobby has besides other modules' screens. A round\n// submits its score to the title's leaderboard, reports itself to quests that count rounds, and\n// feeds the events and seasons that pay for a round; the table itself is the leaderboard-ui screen\n// (\"Leaderboard\" opens it).\n\nexport function makePlayScreen(features: FeatureRegistry) {\n return function PlayScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\") ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n };\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"path": "module.meta.json",
|
|
89
|
+
"content": "{\n \"id\": \"showcase\",\n \"type\": \"template\",\n \"summary\": \"The lobby a new title starts from: balances, the profile and the game's screens (shop, heroes, leaders, rewards, …) as tabs and 'More', plus a tiny mini-game.\",\n \"description\": \"The minimal complete app a new title starts from when no other template fits the idea. It is a LOBBY: every screen is another catalog module registered as a feature of the game (ctx.features) — store-ui, inventory-ui, leaderboard-ui, rewards-ui and the other *-ui modules — and the lobby lays out whatever is installed: tabs at the bottom with 'More' on a phone, a centred tab pill on a tablet, a side rail on a desktop, badges on every tab. Its own content is only a 15-second tap mini-game that submits a score to the title's leaderboard and reports rounds to quests. The header shows the balances (reward icons fly into them) and the player's profile. Remove a screen module from src/modules.ts and its tab disappears; add one and it appears. Ships title-seed.json with the currencies and items the screens share; each screen module brings its own section.\",\n \"provides\": [\n \"lobby for a game made of screen modules\",\n \"bottom tabs with More on phones, tab pill on tablets, side rail on desktops\",\n \"badges on tabs\",\n \"balance counters that reward icons fly into\",\n \"player profile button\",\n \"tap mini-game that submits a leaderboard score\",\n \"demo currencies and items\"\n ],\n \"tags\": [\n \"starter\",\n \"showcase\",\n \"lobby\",\n \"menu\",\n \"tabs\",\n \"mini-game\",\n \"casual\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|