@idosgames/mcp 0.1.43 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +144 -144
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +15 -15
- package/registry/modules/boosts.json +5 -5
- package/registry/modules/character.json +11 -11
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +21 -17
- package/registry/modules/idle-rpg.json +20 -16
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +7 -7
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +9 -9
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +9 -9
- package/registry/modules/voxelcraft.json +37 -29
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/ai-generation-system.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/rewarded-ads.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"version": "0.1.3"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@idosgames/core": "0.
|
|
35
|
-
"@idosgames/module-sdk": "0.13.
|
|
36
|
-
"@idosgames/react": "0.
|
|
34
|
+
"@idosgames/core": "0.26.0",
|
|
35
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
36
|
+
"@idosgames/react": "0.7.0",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
|
-
"contentHash": "
|
|
39
|
+
"contentHash": "ab99622e4ba739d6e189e21c2e41c38125b2b2d140c2c63f670dd2205dca3efc",
|
|
40
40
|
"files": [
|
|
41
41
|
{
|
|
42
42
|
"path": "i18n.ts",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"path": "RewardsScreen.tsx",
|
|
59
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n grantLines,\n Icon,\n inkFrame,\n inked,\n outlined,\n panel,\n Popup,\n Ribbon,\n tokenGlyph,\n TONES,\n type ButtonTone,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards in the casual sheet's \"Daily Bonus\" look: the red ribbon, a grid of blue cards with\n// the day's number above each — a milestone day on a coloured plate, today's card framed white and\n// glowing, the collected ones dimmed under a tick — and the green Claim. Quests are a system of their\n// own (the quests module).\n\n/** The plates of milestone days in turn (the sheet's green, purple and red specials). */\nconst MILESTONE_TONES: ButtonTone[] = [\"green\", \"purple\", \"red\", \"orange\"];\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // The day turns by the clock (counted locally); opening the screen re-checks the state against\n // changes made elsewhere through the central cache.\n useEffect(() => {\n void client.cache.ensureState([\"Reward\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n return <DailyCalendar rewardState={state?.Reward ?? null} />;\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(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 };\n\n return (\n // A week is a compact card on any width — three columns stretched across a monitor made\n // giant tiles; the popup is narrower than this anyway.\n <div style={{ maxWidth: 520, margin: \"0 auto\" }}>\n {compact ? null : (\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n margin: \"4px 0 14px\",\n }}\n >\n <Ribbon tone=\"red\" size={22}>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </Ribbon>\n </div>\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 const collected = state === \"collected\";\n const tone: ButtonTone = day.IsMilestone\n ? MILESTONE_TONES[\n view.days.slice(0, i).filter((d) => d.day.IsMilestone)\n .length % MILESTONE_TONES.length\n ]!\n : \"blue\";\n const [top, deep] = TONES[tone];\n return (\n <div\n key={day.DayNumber ?? i}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 3,\n ...stagger(i),\n }}\n >\n <span\n style={{\n ...inked,\n fontSize: 13,\n color: `color-mix(in srgb, ${v.blue} 35%, #fff)`,\n }}\n >\n {day.DayNumber ?? i + 1}\n </span>\n <div\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n position: \"relative\",\n width: \"100%\",\n aspectRatio: span > 1 ? undefined : \"1\",\n minHeight: 78,\n borderRadius: 10,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 40%, ${deep} 100%)`,\n // the white frame is an outline: the glow animates box-shadow and would wipe it\n outline: today ? \"3px solid #ffffff\" : undefined,\n boxShadow: today\n ? `0 0 16px 4px color-mix(in srgb, #fff 55%, transparent)`\n : `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.4)`,\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"center\",\n gap: 2,\n padding: \"6px 2px\",\n boxSizing: \"border-box\",\n opacity: collected ? 0.6 : 1,\n }}\n >\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 ? 52 : 44}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...inked, fontSize: 18 }}>\n {main.amount}\n </span>\n ) : null}\n {collected ? (\n // a tick in the corner — the reward under it stays readable\n <span style={{ position: \"absolute\", right: 4, bottom: 4 }}>\n <Icon glyph=\"check\" size={28} />\n </span>\n ) : null}\n </div>\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// ── 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 (the daily bonus waiting).\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 // The calendar is in the login state; the central cache asks the server only when the title\n // tracks comeback presence on read.\n void client.cache.ensureState([\"Reward\"]).then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n if (!calendar) return;\n const s = (client.data.user.state?.Reward as RewardState | undefined)\n ?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change of it. Subscribed directly: the cache\n // patches the state in place, so an effect keyed on the state object would not re-run after a\n // claim and the badge would stay lit. The minute timer opens the next day's claim.\n useEffect(() => {\n const update = () => {\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (client.data.user.state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\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"
|
|
59
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n grantLines,\n Icon,\n inkFrame,\n inked,\n outlined,\n panel,\n Popup,\n Ribbon,\n tokenGlyph,\n TONES,\n type ButtonTone,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards in the casual sheet's \"Daily Bonus\" look: the red ribbon, a grid of blue cards with\n// the day's number above each — a milestone day on a coloured plate, today's card framed white and\n// glowing, the collected ones dimmed under a tick — and the green Claim. Quests are a system of their\n// own (the quests module).\n\n/** The plates of milestone days in turn (the sheet's green, purple and red specials). */\nconst MILESTONE_TONES: ButtonTone[] = [\"green\", \"purple\", \"red\", \"orange\"];\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // The day turns by the clock (counted locally); opening the screen re-checks the state against\n // changes made elsewhere through the central cache.\n useEffect(() => {\n void client.cache.ensureState([\"Reward\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n return <DailyCalendar rewardState={state?.Reward ?? null} />;\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(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 };\n\n return (\n // A week is a compact card on any width — three columns stretched across a monitor made\n // giant tiles; the popup is narrower than this anyway.\n <div style={{ maxWidth: 520, margin: \"0 auto\" }}>\n {compact ? null : (\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n margin: \"4px 0 14px\",\n }}\n >\n <Ribbon tone=\"red\" size={22}>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </Ribbon>\n </div>\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 const collected = state === \"collected\";\n const tone: ButtonTone = day.IsMilestone\n ? MILESTONE_TONES[\n view.days.slice(0, i).filter((d) => d.day.IsMilestone)\n .length % MILESTONE_TONES.length\n ]!\n : \"blue\";\n const [top, deep] = TONES[tone];\n return (\n <div\n key={day.DayNumber ?? i}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 3,\n ...stagger(i),\n }}\n >\n <span\n style={{\n ...inked,\n fontSize: 13,\n color: `color-mix(in srgb, ${v.blue} 35%, #fff)`,\n }}\n >\n {day.DayNumber ?? i + 1}\n </span>\n <div\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n position: \"relative\",\n width: \"100%\",\n aspectRatio: span > 1 ? undefined : \"1\",\n minHeight: 78,\n borderRadius: 10,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 40%, ${deep} 100%)`,\n // today: the white frame is an outline, the gold glow the kit's .idos-glow (its own layer)\n outline: today ? \"3px solid #ffffff\" : undefined,\n boxShadow: today\n ? undefined\n : `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.4)`,\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"center\",\n gap: 2,\n padding: \"6px 2px\",\n boxSizing: \"border-box\",\n opacity: collected ? 0.6 : 1,\n }}\n >\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 ? 52 : 44}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...inked, fontSize: 18 }}>\n {main.amount}\n </span>\n ) : null}\n {collected ? (\n // a tick in the corner — the reward under it stays readable\n <span style={{ position: \"absolute\", right: 4, bottom: 4 }}>\n <Icon glyph=\"check\" size={28} />\n </span>\n ) : null}\n </div>\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// ── 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 (the daily bonus waiting).\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 // The calendar is in the login state; the central cache asks the server only when the title\n // tracks comeback presence on read.\n void client.cache.ensureState([\"Reward\"]).then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n if (!calendar) return;\n const s = (client.data.user.state?.Reward as RewardState | undefined)\n ?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change of it. Subscribed directly: the cache\n // patches the state in place, so an effect keyed on the state object would not re-run after a\n // claim and the badge would stay lit. The minute timer opens the next day's claim.\n useEffect(() => {\n const update = () => {\n const calendar = pickCalendar(configSection(client, \"Reward\"));\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (client.data.user.state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n const id = setInterval(update, 60_000);\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\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"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "module.meta.json",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.26.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.0",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"name": "iDos Games",
|
|
34
34
|
"url": "https://idosgames.com"
|
|
35
35
|
},
|
|
36
|
-
"version": "0.1.
|
|
36
|
+
"version": "0.1.4"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.26.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
41
|
+
"@idosgames/react": "0.7.0",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "e702aa4d3c44024f0c964d4b3c791599a8724bece9020895df2de3286ad204ff",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "model.ts",
|
|
56
|
-
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\n/**\n * Price in Credits (platform currency) of the first option, or null when the offer is not sold for Credits. A Credits price\n * is not a game resource: the card shows it as text, and the purchase goes through the player's confirmation on\n * idosgames.com (`client.store.purchaseWithCredits`).\n */\nexport function creditsPriceOf(offer: StorefrontOfferView): number | null {\n const option = offer.PriceOptions?.[0];\n return option?.IsCreditsPaid ? Number(option.CreditsPrice ?? 0) : null;\n}\n\n/** Price lines in game resources — without the Credits entry (it is drawn by `creditsPriceOf`). */\nexport function resourcePrice(offer: StorefrontOfferView) {\n return costLines(offer.PriceOptions?.[0]?.Cost).filter(\n (line) =>
|
|
56
|
+
"content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\n/**\n * Price in Credits (platform currency) of the first option, or null when the offer is not sold for Credits. A Credits price\n * is not a game resource: the card shows it as text, and the purchase goes through the player's confirmation on\n * idosgames.com (`client.store.purchaseWithCredits`).\n */\nexport function creditsPriceOf(offer: StorefrontOfferView): number | null {\n const option = offer.PriceOptions?.[0];\n return option?.IsCreditsPaid ? Number(option.CreditsPrice ?? 0) : null;\n}\n\n/** Price lines in game resources — without the Credits entry (it is drawn by `creditsPriceOf`). */\nexport function resourcePrice(offer: StorefrontOfferView) {\n return costLines(offer.PriceOptions?.[0]?.Cost).filter(\n (line) => line.kind !== \"credits\",\n );\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n if (creditsPriceOf(offer) != null) return false;\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "module.ts",
|
|
@@ -61,15 +61,15 @@
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "PlatformCurrencyIcon.tsx",
|
|
64
|
-
"content": "
|
|
64
|
+
"content": "// The Aspis icon and \"[icon] 100 Aspis\" now live in the UI kit (`@idosgames/react/ui`): every system that sells for\n// Aspis draws the same one. Kept as a re-export so imports of this file keep working.\nexport { PlatformCurrencyIcon, CreditsPrice } from \"@idosgames/react/ui\";\n"
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "StoreScreen.tsx",
|
|
68
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n CreditsError,\n OFFER_NOT_IN_ROTATION,\n type CreditsConfirmationView,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { CreditsPrice } from \"./PlatformCurrencyIcon\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n creditsPriceOf,\n earliestRefresh,\n isFree,\n resourcePrice,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = resourcePrice(offer);\n const creditsPrice = creditsPriceOf(offer);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45), 0 4px 0 ${v.shadow}`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : creditsPrice != null ? (\n <CreditsPrice\n amount={creditsPrice}\n label={t(\"currencyName\")}\n size={20}\n />\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = resourcePrice(picked.offer);\n const creditsPrice = creditsPriceOf(picked.offer);\n const free = isFree(picked.offer);\n // Credits: the request waiting for the player on idosgames.com. Leaving the popup stops the wait.\n const [confirm, setConfirm] = useState<CreditsConfirmationView | null>(null);\n const abort = useRef<AbortController | null>(null);\n useEffect(() => () => abort.current?.abort(), []);\n\n const creditsError = (error: string | undefined): string => {\n if (error === CreditsError.LoginRequired) return t(\"creditsLogin\");\n if (error === CreditsError.NotEnough) return t(\"creditsNotEnough\");\n if (error === CreditsError.ConfirmationDeclined)\n return t(\"creditsDeclined\");\n if (error === CreditsError.ConfirmationExpired) return t(\"creditsExpired\");\n return errorText(error);\n };\n\n const buy = async () => {\n setBusy(true);\n const slot = {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n };\n abort.current = new AbortController();\n const res =\n creditsPrice != null\n ? await client.store.purchaseWithCredits(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot,\n onConfirmation: setConfirm,\n signal: abort.current.signal,\n })\n : await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot,\n });\n setBusy(false);\n setConfirm(null);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n if (res.error === \"CREDITS_CONFIRMATION_CANCELLED\") return;\n if (res.error === CreditsError.NotEnough) {\n const balance = await client.store.getCreditsBalance();\n const url = balance.ok ? balance.data.TopUpUrl : null;\n if (url) window.open(url, \"_blank\", \"noopener\");\n }\n toast(\n creditsPrice != null ? creditsError(res.error) : errorText(res.error),\n \"error\",\n );\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n if (confirm) {\n // A link, not window.open after an await: the browser blocks popups that are not a direct tap.\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 10, textAlign: \"center\" }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {t(\"creditsConfirmHint\")}\n </span>\n <a\n href={confirm.Url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n style={{ textDecoration: \"none\" }}\n >\n <Button tone=\"gold\" size=\"lg\" style={{ width: \"100%\" }}>\n {t(\"creditsConfirmOpen\")}\n </Button>\n </a>\n <span style={{ ...outlined, fontSize: 13, opacity: 0.8 }}>\n {t(\"creditsWaiting\")}\n </span>\n </div>\n );\n }\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : creditsPrice != null ? (\n <>\n {t(\"confirmBuy\")}{\" \"}\n <CreditsPrice\n amount={creditsPrice}\n label={t(\"currencyName\")}\n size={20}\n />\n </>\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
|
|
68
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n CreditsError,\n OFFER_NOT_IN_ROTATION,\n type CreditsConfirmationView,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { CreditsPrice } from \"./PlatformCurrencyIcon\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n creditsPriceOf,\n earliestRefresh,\n isFree,\n resourcePrice,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n//\n// ⚠ An offer priced in Aspis (Credits) has NO game popup (owner, 04.11.2026: \"no double confirmation\"): the tap\n// starts the purchase at once and the ONE confirmation is the window of idosgames.com / the app over the game. The\n// game's popup appears only when the site could not ask (the game is not on idosgames.com) — with the link to the\n// confirmation page.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\n/** An Aspis purchase in flight; `view` is set when the site could not ask and the player needs the link. */\ninterface CreditsBuy {\n picked: Picked;\n view: CreditsConfirmationView | null;\n}\n\nfunction slotOf(picked: Picked) {\n return {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n };\n}\n\nfunction creditsError(error: string | undefined): string {\n if (error === CreditsError.LoginRequired) return t(\"creditsLogin\");\n if (error === CreditsError.NotEnough) return t(\"creditsNotEnough\");\n if (error === CreditsError.ConfirmationDeclined) return t(\"creditsDeclined\");\n if (error === CreditsError.ConfirmationExpired) return t(\"creditsExpired\");\n return errorText(error);\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [credits, setCredits] = useState<CreditsBuy | null>(null);\n const creditsAbort = useRef<AbortController | null>(null);\n useEffect(() => () => creditsAbort.current?.abort(), []);\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n // Aspis: straight to the site's window, no popup of our own (see the note at the top).\n const buyWithCredits = async (target: Picked) => {\n if (creditsAbort.current) return; // one Aspis purchase at a time\n const ac = new AbortController();\n creditsAbort.current = ac;\n setCredits({ picked: target, view: null });\n const res = await client.store.purchaseWithCredits(\n target.offer.OfferID,\n 1,\n {\n selectedOptionID: target.offer.PriceOptions?.[0]?.OptionID,\n slot: slotOf(target),\n onConfirmation: (view) =>\n setCredits((cur) => (cur ? { ...cur, view } : cur)),\n signal: ac.signal,\n },\n );\n if (creditsAbort.current !== ac) return; // closed by the player\n creditsAbort.current = null;\n setCredits(null);\n if (!res.ok) {\n if (res.error === \"CREDITS_CONFIRMATION_CANCELLED\") return;\n if (res.error === OFFER_NOT_IN_ROTATION) return void (await load());\n if (res.error === CreditsError.NotEnough) {\n const balance = await client.store.getCreditsBalance();\n const url = balance.ok ? balance.data.TopUpUrl : null;\n if (url) window.open(url, \"_blank\", \"noopener\");\n }\n toast(creditsError(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(target.offer.Rewards));\n await load();\n };\n\n const pick = (target: Picked) => {\n if (creditsPriceOf(target.offer) != null) void buyWithCredits(target);\n else setPicked(target);\n };\n\n const closeCredits = () => {\n creditsAbort.current?.abort();\n creditsAbort.current = null;\n setCredits(null);\n };\n\n const buyingSlot = credits?.picked.slot.SlotID ?? null;\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n busy={buyingSlot === slot.SlotID}\n onPick={(offer) =>\n pick({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n busy={buyingSlot === slot.SlotID}\n onPick={(offer) =>\n pick({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n {credits?.view ? (\n <Popup title={t(\"confirmTitle\")} onClose={closeCredits}>\n <OfferPreview offer={credits.picked.offer} />\n <CreditsLink view={credits.view} />\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n busy = false,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n /** An Aspis purchase of this card waits for the site's window. */\n busy?: boolean;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = resourcePrice(offer);\n const creditsPrice = creditsPriceOf(offer);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45)`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n busy={busy}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : creditsPrice != null ? (\n <CreditsPrice\n amount={creditsPrice}\n label={t(\"currencyName\")}\n size={20}\n />\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <OfferPreview offer={picked.offer} />\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\n/** The offer's name and what it grants — the top of the confirmation popup. */\nfunction OfferPreview({ offer }: { offer: StorefrontOfferView }): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(offer.Rewards);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\" ? tokenGlyph(line) : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n );\n}\n\n/**\n * The site could not show its window (the game is not on idosgames.com): the request waits for the player on the\n * confirmation page. A link, not window.open after an await: the browser blocks popups that are not a direct tap.\n */\nfunction CreditsLink({ view }: { view: CreditsConfirmationView }): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 10, textAlign: \"center\" }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {t(\"creditsConfirmHint\")}\n </span>\n <a\n href={view.Url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n style={{ textDecoration: \"none\" }}\n >\n <Button tone=\"gold\" size=\"lg\" style={{ width: \"100%\" }}>\n {t(\"creditsConfirmOpen\")}\n </Button>\n </a>\n <span style={{ ...outlined, fontSize: 13, opacity: 0.8 }}>\n {t(\"creditsWaiting\")}\n </span>\n </div>\n );\n}\n\n/** Buys an offer priced in game resources (or free). Aspis offers never get here — see `buyWithCredits`. */\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = resourcePrice(picked.offer);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: slotOf(picked),\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
"path": "module.meta.json",
|
|
72
|
-
"content": "{\n \"id\": \"store\",\n \"type\": \"feature\",\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
72
|
+
"content": "{\n \"id\": \"store\",\n \"type\": \"feature\",\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
|
|
73
73
|
}
|
|
74
74
|
]
|
|
75
75
|
}
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
"name": "iDos Games",
|
|
30
30
|
"url": "https://idosgames.com"
|
|
31
31
|
},
|
|
32
|
-
"version": "0.1.
|
|
32
|
+
"version": "0.1.4"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.
|
|
35
|
+
"@idosgames/core": "0.26.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
37
|
+
"@idosgames/react": "0.7.0",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
|
-
"contentHash": "
|
|
40
|
+
"contentHash": "da4dbd714014f6a0f2a2d761ac737dc2e70f5c466ec05f9cc51194d3cd87f40f",
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
|
43
43
|
"path": "i18n.ts",
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "TutorialOverlay.tsx",
|
|
60
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n bundleLines,\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n inked,\n outlined,\n panel,\n Spinner,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n activeFlow,\n anchorOf,\n bubblePlacement,\n canTapNext,\n stepCounter,\n stepText,\n type FlowView,\n type Rect,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The tutorial overlay (Unity Runtime/UI/Tutorial): the current step of the running flow as a hint\n// bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module,\n// like Unity's TutorialAnchorRegistry. It waits while a popup is open, \"Next\" only on steps the\n// player closes, and the reward when the flow ends. The \"Tutorials\" feature replays a flow.\n\n/** Tutorial state shared by the overlay and the replay screen. */\nexport function createTutorialStore() {\n let flows: FlowView[] | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => flows,\n set(next: FlowView[] | null) {\n flows = next;\n for (const l of listeners) l();\n },\n subscribe(l: () => void) {\n listeners.add(l);\n return () => {\n listeners.delete(l);\n };\n },\n };\n}\nexport type TutorialStore = ReturnType<typeof createTutorialStore>;\n\nconst BUBBLE = { width: 320, height: 150 };\n\n/** What an applied operation granted: the standard part plus premium bundles. */\nfunction operationLines(op: unknown) {\n const grant = (\n op as\n | {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Grant;\n return [\n ...bundleLines(grant?.Standard as never),\n ...(grant?.PremiumTiers ?? []).flatMap((t) =>\n bundleLines(t?.Resources as never),\n ),\n ];\n}\n\nfunction modalOpen(): boolean {\n return document.querySelector(\"[data-idos-ui-layer] [role=dialog]\") !== null;\n}\n\nexport function makeTutorialOverlay(store: TutorialStore) {\n return function TutorialOverlay(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const [flows, setFlows] = useState<FlowView[] | null>(store.get());\n const [rect, setRect] = useState<Rect | null>(null);\n const [blocked, setBlocked] = useState(true);\n const [busy, setBusy] = useState(false);\n const shown = useRef(new Set<string>());\n\n const load = useCallback(async () => {\n const res = await client.tutorial.getUserTutorialState();\n store.set(res.ok ? ((res.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n\n useEffect(() => store.subscribe(() => setFlows(store.get())), []);\n useEffect(() => {\n void load();\n // A SystemEvent step closes on a game action; the progress arrives with that action's reply.\n const off = client.on(\"tutorial:systemProgress\", () => void load());\n return () => off();\n }, [client, load]);\n\n const flow = activeFlow(flows);\n const step = flow?.Status === \"InProgress\" ? flow.CurrentStep : null;\n const anchor = anchorOf(step);\n\n // Follow the anchor (it moves with layout, scroll and screen changes) and step aside for popups.\n useEffect(() => {\n if (!flow) return;\n const tick = () => {\n setBlocked(modalOpen());\n const el = anchor\n ? document.querySelector(\n `[data-tutorial-anchor=\"${CSS.escape(anchor)}\"]`,\n )\n : null;\n const r = el?.getBoundingClientRect();\n setRect(\n r && r.width > 0 && r.height > 0\n ? { left: r.left, top: r.top, width: r.width, height: r.height }\n : null,\n );\n };\n tick();\n const id = setInterval(tick, 250);\n return () => clearInterval(id);\n }, [flow?.FlowID, flow?.CurrentStepID, anchor]);\n\n // The funnel measures time on step from this report — once per step shown.\n useEffect(() => {\n if (!flow || !flow.CurrentStepID || blocked) return;\n const key = `${flow.FlowID}:${flow.CurrentStepID}`;\n if (shown.current.has(key)) return;\n shown.current.add(key);\n kit.play(\"notify\");\n void client.tutorial.reportStepShown(flow.FlowID, flow.CurrentStepID);\n }, [flow?.FlowID, flow?.CurrentStepID, blocked]);\n\n if (!flow || blocked) return null;\n\n const act = async (\n fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,\n ) => {\n setBusy(true);\n const res = await fn();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error ?? \"\"), \"error\");\n await load();\n };\n\n const text = step\n ? stepText(step, (k) => catalog.localize(k))\n : { title: t(\"done\"), body: \"\" };\n const place = bubblePlacement(\n step ? rect : null,\n { width: window.innerWidth, height: window.innerHeight },\n BUBBLE,\n );\n const pad = 6;\n\n return (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 45,\n pointerEvents: \"none\",\n }}\n data-idos-tutorial\n >\n {step && rect ? (\n <div\n className=\"idos-pulse\"\n style={{\n position: \"fixed\",\n left: rect.left - pad,\n top: rect.top - pad,\n width: rect.width + pad * 2,\n height: rect.height + pad * 2,\n borderRadius: 14,\n border: `3px solid ${v.gold}`,\n boxShadow: `0 0 0 9999px ${v.backdrop}`,\n transition: \"all .25s ease\",\n }}\n />\n ) : (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n background: v.backdrop,\n }}\n />\n )}\n {/* The sheet's \"Welcome!\" bubble: white in the ink frame, dark letters, a light-blue lip. */}\n <div\n style={{\n position: \"fixed\",\n left: place.left,\n top: place.top,\n width: Math.min(BUBBLE.width, window.innerWidth - 24),\n padding: \"14px 16px 12px\",\n display: \"grid\",\n gap: 8,\n background: \"#ffffff\",\n border: `${v.outline} solid ${v.ink}`,\n borderRadius: 14,\n boxShadow: `inset 0 -5px 0 color-mix(in srgb, ${v.blue} 35%, #fff), 0 4px 0 ${v.shadow}`,\n color: \"#17193a\",\n pointerEvents: \"auto\",\n animation: `idos-pop-in ${v.slow} ease both`,\n boxSizing: \"border-box\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Icon glyph={step ? \"bulb\" : \"trophy\"} size={26} />\n <div style={{ flex: 1, fontWeight: 900, fontSize: 17 }}>\n {text.title}\n </div>\n {step ? (\n <span\n style={{ fontWeight: 900, fontSize: 12, color: v.blueDeep }}\n >\n {stepCounter(flow)}\n </span>\n ) : null}\n </div>\n {text.body ? (\n <div style={{ fontSize: 15, fontWeight: 800, lineHeight: 1.35 }}>\n {text.body}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"flex-end\",\n alignItems: \"center\",\n }}\n >\n {step && flow.CanSkip ? (\n // The sheet's \"SKIP ≫\": outlined white letters, no plate.\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={busy}\n onClick={() =>\n void act(() => client.tutorial.skipFlow(flow.FlowID))\n }\n style={{\n ...inked,\n border: \"none\",\n background: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n textTransform: \"uppercase\",\n marginRight: \"auto\",\n padding: \"4px 2px\",\n color: v.blue,\n }}\n >\n {t(\"skip\")} ≫\n </button>\n ) : null}\n {step ? (\n canTapNext(step) ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() =>\n void act(() =>\n client.tutorial.completeStep(\n flow.FlowID,\n flow.CurrentStepID!,\n ),\n )\n }\n data-tutorial-anchor=\"tutorial:next\"\n >\n {t(\"next\")}\n </Button>\n ) : (\n <span\n style={{ fontWeight: 800, fontSize: 12, color: v.blueDeep }}\n >\n {t(\"waiting\")}\n </span>\n )\n ) : (\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() =>\n void act(async () => {\n const res = await client.tutorial.claimFlowReward(\n flow.FlowID,\n );\n // The payout sits in `Granted` (an operation), not where the generic reader looks.\n if (res.ok) celebrate(operationLines(res.data.Granted));\n return res;\n })\n }\n >\n {t(\"claim\")}\n </Button>\n )}\n </div>\n </div>\n </div>\n );\n };\n}\n\ntype FlowDef = {\n Identity?: { DisplayName?: string | null; SortOrder?: number | null } | null;\n Policy?: { RestartPolicy?: string | null } | null;\n};\n\n/** \"Tutorials\": every flow of the title with its status; replay where the title allows it. */\nexport function makeTutorialsScreen(store: TutorialStore) {\n return function TutorialsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const stagger = useStagger();\n const [defs, setDefs] = useState<Record<string, FlowDef | null> | null>(\n null,\n );\n const [flows, setFlows] = useState<FlowView[]>([]);\n const [busy, setBusy] = useState<string | null>(null);\n const load = useCallback(async () => {\n // Listing must not start anything — autoStart off.\n const [d, s] = await Promise.all([\n client.tutorial.getTutorialDefinitions(),\n client.tutorial.getUserTutorialState(false),\n ]);\n setDefs(\n d.ok ? ((d.data.Flows ?? {}) as Record<string, FlowDef | null>) : {},\n );\n setFlows(s.ok ? ((s.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const ids = Object.keys(defs).sort(\n (a, b) =>\n Number(defs[a]?.Identity?.SortOrder ?? 0) -\n Number(defs[b]?.Identity?.SortOrder ?? 0),\n );\n if (ids.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noTutorials\")} />;\n const replay = async (id: string) => {\n setBusy(id);\n const reset = await client.tutorial.resetFlow(id);\n const started = reset.ok ? await client.tutorial.startFlow(id) : reset;\n setBusy(null);\n if (!started.ok) return toast(errorText(started.error), \"error\");\n const s = await client.tutorial.getUserTutorialState(false);\n if (s.ok) store.set((s.data.Flows ?? []) as FlowView[]);\n await load();\n };\n return (\n <Columns gap={8} min={340}>\n {ids.map((id, i) => {\n const view = flows.find((f) => f.FlowID === id);\n const status = view?.Status ?? \"NotStarted\";\n const label =\n status === \"Completed\"\n ? t(\"completed\")\n : status === \"InProgress\"\n ? t(\"inProgress\")\n : status === \"Skipped\"\n ? t(\"skipped\")\n : t(\"notStarted\");\n const canReplay =\n defs[id]?.Policy?.RestartPolicy === \"OnRequest\" &&\n (status === \"Completed\" || status === \"Skipped\");\n return (\n <div\n key={id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"bulb\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(defs[id]?.Identity?.DisplayName ?? id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </div>\n </div>\n {canReplay ? (\n <Button\n size=\"sm\"\n tone=\"blue\"\n busy={busy === id}\n onClick={() => void replay(id)}\n >\n {t(\"replay\")}\n </Button>\n ) : null}\n </div>\n );\n })}\n </Columns>\n );\n };\n}\n"
|
|
60
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n bundleLines,\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n inked,\n outlined,\n panel,\n Spinner,\n useCatalog,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n activeFlow,\n anchorOf,\n bubblePlacement,\n canTapNext,\n stepCounter,\n stepText,\n type FlowView,\n type Rect,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The tutorial overlay (Unity Runtime/UI/Tutorial): the current step of the running flow as a hint\n// bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module,\n// like Unity's TutorialAnchorRegistry. It waits while a popup is open, \"Next\" only on steps the\n// player closes, and the reward when the flow ends. The \"Tutorials\" feature replays a flow.\n\n/** Tutorial state shared by the overlay and the replay screen. */\nexport function createTutorialStore() {\n let flows: FlowView[] | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => flows,\n set(next: FlowView[] | null) {\n flows = next;\n for (const l of listeners) l();\n },\n subscribe(l: () => void) {\n listeners.add(l);\n return () => {\n listeners.delete(l);\n };\n },\n };\n}\nexport type TutorialStore = ReturnType<typeof createTutorialStore>;\n\nconst BUBBLE = { width: 320, height: 150 };\n\n/** What an applied operation granted: the standard part plus premium bundles. */\nfunction operationLines(op: unknown) {\n const grant = (\n op as\n | {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Grant;\n return [\n ...bundleLines(grant?.Standard as never),\n ...(grant?.PremiumTiers ?? []).flatMap((t) =>\n bundleLines(t?.Resources as never),\n ),\n ];\n}\n\nfunction modalOpen(): boolean {\n return document.querySelector(\"[data-idos-ui-layer] [role=dialog]\") !== null;\n}\n\nexport function makeTutorialOverlay(store: TutorialStore) {\n return function TutorialOverlay(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const [flows, setFlows] = useState<FlowView[] | null>(store.get());\n const [rect, setRect] = useState<Rect | null>(null);\n const [blocked, setBlocked] = useState(true);\n const [busy, setBusy] = useState(false);\n const shown = useRef(new Set<string>());\n\n const load = useCallback(async () => {\n const res = await client.tutorial.getUserTutorialState();\n store.set(res.ok ? ((res.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n\n useEffect(() => store.subscribe(() => setFlows(store.get())), []);\n useEffect(() => {\n void load();\n // A SystemEvent step closes on a game action; the progress arrives with that action's reply.\n const off = client.on(\"tutorial:systemProgress\", () => void load());\n return () => off();\n }, [client, load]);\n\n const flow = activeFlow(flows);\n const step = flow?.Status === \"InProgress\" ? flow.CurrentStep : null;\n const anchor = anchorOf(step);\n\n // Follow the anchor (it moves with layout, scroll and screen changes) and step aside for popups.\n useEffect(() => {\n if (!flow) return;\n const tick = () => {\n setBlocked(modalOpen());\n const el = anchor\n ? document.querySelector(\n `[data-tutorial-anchor=\"${CSS.escape(anchor)}\"]`,\n )\n : null;\n const r = el?.getBoundingClientRect();\n const next =\n r && r.width > 0 && r.height > 0\n ? { left: r.left, top: r.top, width: r.width, height: r.height }\n : null;\n // the same box keeps the same object: a new one every 250 ms re-rendered the overlay (and the\n // spotlight over the whole screen) four times a second on a phone for nothing\n setRect((prev) =>\n prev &&\n next &&\n prev.left === next.left &&\n prev.top === next.top &&\n prev.width === next.width &&\n prev.height === next.height\n ? prev\n : next,\n );\n };\n tick();\n const id = setInterval(tick, 250);\n return () => clearInterval(id);\n }, [flow?.FlowID, flow?.CurrentStepID, anchor]);\n\n // The funnel measures time on step from this report — once per step shown.\n useEffect(() => {\n if (!flow || !flow.CurrentStepID || blocked) return;\n const key = `${flow.FlowID}:${flow.CurrentStepID}`;\n if (shown.current.has(key)) return;\n shown.current.add(key);\n kit.play(\"notify\");\n void client.tutorial.reportStepShown(flow.FlowID, flow.CurrentStepID);\n }, [flow?.FlowID, flow?.CurrentStepID, blocked]);\n\n if (!flow || blocked) return null;\n\n const act = async (\n fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,\n ) => {\n setBusy(true);\n const res = await fn();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error ?? \"\"), \"error\");\n await load();\n };\n\n const text = step\n ? stepText(step, (k) => catalog.localize(k))\n : { title: t(\"done\"), body: \"\" };\n const place = bubblePlacement(\n step ? rect : null,\n { width: window.innerWidth, height: window.innerHeight },\n BUBBLE,\n );\n const pad = 6;\n\n return (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 45,\n pointerEvents: \"none\",\n }}\n data-idos-tutorial\n >\n {step && rect ? (\n <div\n className=\"idos-pulse\"\n style={{\n position: \"fixed\",\n left: rect.left - pad,\n top: rect.top - pad,\n width: rect.width + pad * 2,\n height: rect.height + pad * 2,\n borderRadius: 14,\n border: `3px solid ${v.gold}`,\n boxShadow: `0 0 0 9999px ${v.backdrop}`,\n transition: \"all .25s ease\",\n }}\n />\n ) : (\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n background: v.backdrop,\n }}\n />\n )}\n {/* The sheet's \"Welcome!\" bubble: white in the ink frame, dark letters, a light-blue lip. */}\n <div\n style={{\n position: \"fixed\",\n left: place.left,\n top: place.top,\n width: Math.min(BUBBLE.width, window.innerWidth - 24),\n padding: \"14px 16px 12px\",\n display: \"grid\",\n gap: 8,\n background: \"#ffffff\",\n border: `${v.outline} solid ${v.ink}`,\n borderRadius: 14,\n boxShadow: `inset 0 -5px 0 color-mix(in srgb, ${v.blue} 35%, #fff)`,\n color: \"#17193a\",\n pointerEvents: \"auto\",\n animation: `idos-pop-in ${v.slow} ease both`,\n boxSizing: \"border-box\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Icon glyph={step ? \"bulb\" : \"trophy\"} size={26} />\n <div style={{ flex: 1, fontWeight: 900, fontSize: 17 }}>\n {text.title}\n </div>\n {step ? (\n <span\n style={{ fontWeight: 900, fontSize: 12, color: v.blueDeep }}\n >\n {stepCounter(flow)}\n </span>\n ) : null}\n </div>\n {text.body ? (\n <div style={{ fontSize: 15, fontWeight: 800, lineHeight: 1.35 }}>\n {text.body}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"flex-end\",\n alignItems: \"center\",\n }}\n >\n {step && flow.CanSkip ? (\n // The sheet's \"SKIP ≫\": outlined white letters, no plate.\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={busy}\n onClick={() =>\n void act(() => client.tutorial.skipFlow(flow.FlowID))\n }\n style={{\n ...inked,\n border: \"none\",\n background: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n textTransform: \"uppercase\",\n marginRight: \"auto\",\n padding: \"4px 2px\",\n color: v.blue,\n }}\n >\n {t(\"skip\")} ≫\n </button>\n ) : null}\n {step ? (\n canTapNext(step) ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() =>\n void act(() =>\n client.tutorial.completeStep(\n flow.FlowID,\n flow.CurrentStepID!,\n ),\n )\n }\n data-tutorial-anchor=\"tutorial:next\"\n >\n {t(\"next\")}\n </Button>\n ) : (\n <span\n style={{ fontWeight: 800, fontSize: 12, color: v.blueDeep }}\n >\n {t(\"waiting\")}\n </span>\n )\n ) : (\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() =>\n void act(async () => {\n const res = await client.tutorial.claimFlowReward(\n flow.FlowID,\n );\n // The payout sits in `Granted` (an operation), not where the generic reader looks.\n if (res.ok) celebrate(operationLines(res.data.Granted));\n return res;\n })\n }\n >\n {t(\"claim\")}\n </Button>\n )}\n </div>\n </div>\n </div>\n );\n };\n}\n\ntype FlowDef = {\n Identity?: { DisplayName?: string | null; SortOrder?: number | null } | null;\n Policy?: { RestartPolicy?: string | null } | null;\n};\n\n/** \"Tutorials\": every flow of the title with its status; replay where the title allows it. */\nexport function makeTutorialsScreen(store: TutorialStore) {\n return function TutorialsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const stagger = useStagger();\n const [defs, setDefs] = useState<Record<string, FlowDef | null> | null>(\n null,\n );\n const [flows, setFlows] = useState<FlowView[]>([]);\n const [busy, setBusy] = useState<string | null>(null);\n const load = useCallback(async () => {\n // Listing must not start anything — autoStart off.\n const [d, s] = await Promise.all([\n client.tutorial.getTutorialDefinitions(),\n client.tutorial.getUserTutorialState(false),\n ]);\n setDefs(\n d.ok ? ((d.data.Flows ?? {}) as Record<string, FlowDef | null>) : {},\n );\n setFlows(s.ok ? ((s.data.Flows ?? []) as FlowView[]) : []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const ids = Object.keys(defs).sort(\n (a, b) =>\n Number(defs[a]?.Identity?.SortOrder ?? 0) -\n Number(defs[b]?.Identity?.SortOrder ?? 0),\n );\n if (ids.length === 0)\n return <EmptyState glyph=\"bulb\" text={t(\"noTutorials\")} />;\n const replay = async (id: string) => {\n setBusy(id);\n const reset = await client.tutorial.resetFlow(id);\n const started = reset.ok ? await client.tutorial.startFlow(id) : reset;\n setBusy(null);\n if (!started.ok) return toast(errorText(started.error), \"error\");\n const s = await client.tutorial.getUserTutorialState(false);\n if (s.ok) store.set((s.data.Flows ?? []) as FlowView[]);\n await load();\n };\n return (\n <Columns gap={8} min={340}>\n {ids.map((id, i) => {\n const view = flows.find((f) => f.FlowID === id);\n const status = view?.Status ?? \"NotStarted\";\n const label =\n status === \"Completed\"\n ? t(\"completed\")\n : status === \"InProgress\"\n ? t(\"inProgress\")\n : status === \"Skipped\"\n ? t(\"skipped\")\n : t(\"notStarted\");\n const canReplay =\n defs[id]?.Policy?.RestartPolicy === \"OnRequest\" &&\n (status === \"Completed\" || status === \"Skipped\");\n return (\n <div\n key={id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"bulb\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(defs[id]?.Identity?.DisplayName ?? id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </div>\n </div>\n {canReplay ? (\n <Button\n size=\"sm\"\n tone=\"blue\"\n busy={busy === id}\n onClick={() => void replay(id)}\n >\n {t(\"replay\")}\n </Button>\n ) : null}\n </div>\n );\n })}\n </Columns>\n );\n };\n}\n"
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "module.meta.json",
|
|
64
|
-
"content": "{\n \"id\": \"tutorials\",\n \"type\": \"feature\",\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
64
|
+
"content": "{\n \"id\": \"tutorials\",\n \"type\": \"feature\",\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
|
|
65
65
|
}
|
|
66
66
|
]
|
|
67
67
|
}
|