@idosgames/mcp 0.1.39 → 0.1.41
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 +106 -106
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +3 -3
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +3 -3
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +3 -3
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +6 -6
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +3 -3
- 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 +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +11 -7
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/video-studio.json +3 -3
- package/registry/modules/voxelcraft.json +3 -3
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/hero-run.json +1 -1
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -76,9 +76,9 @@
|
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@idosgames/characters": "0.1.1",
|
|
79
|
-
"@idosgames/core": "0.
|
|
80
|
-
"@idosgames/module-sdk": "0.13.
|
|
81
|
-
"@idosgames/react": "0.6.
|
|
79
|
+
"@idosgames/core": "0.24.1",
|
|
80
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
81
|
+
"@idosgames/react": "0.6.1",
|
|
82
82
|
"phaser": "4.2.1",
|
|
83
83
|
"react": "19.2.7"
|
|
84
84
|
},
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.1"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.6.
|
|
35
|
+
"@idosgames/core": "0.24.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
37
|
+
"@idosgames/react": "0.6.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "0b02bc09a1a29ecb6d08fa281dfe316b01f8f341350ba27f386a05572827700e",
|
|
@@ -35,16 +35,16 @@
|
|
|
35
35
|
"version": "0.1.3"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@idosgames/core": "0.
|
|
39
|
-
"@idosgames/module-sdk": "0.13.
|
|
40
|
-
"@idosgames/react": "0.6.
|
|
38
|
+
"@idosgames/core": "0.24.1",
|
|
39
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
40
|
+
"@idosgames/react": "0.6.1",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "0e2ffb620e408e8a97d16de8ab88b36c09e7295d3e84d4f36f746e9beb08666b",
|
|
44
44
|
"files": [
|
|
45
45
|
{
|
|
46
46
|
"path": "i18n.ts",
|
|
47
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Invite\",\n disabled: \"Invites are turned off in this game.\",\n yourCode: \"Your invite code\",\n copyCode: \"Copy code\",\n share: \"Share link\",\n copied: \"Copied\",\n joined: \"Friends who came by your code\",\n rewards: \"Rewards for friends\",\n friends: \"friend|friends\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n enterCode: \"Enter a friend's code\",\n enterHint: \"Got a code from a friend? Enter it and you both get a gift.\",\n youGet: \"You get\",\n activate: \"Activate\",\n badCode: \"A code is 8 letters, like WDJB-MJHT\",\n activated: \"Code activated!\",\n youCameBy: \"You joined by a friend's code\",\n inviteText: \"Join me in the game! My code:\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Пригласить\",\n disabled: \"Приглашения в этой игре выключены.\",\n yourCode: \"Ваш код приглашения\",\n copyCode: \"Скопировать код\",\n share: \"Поделиться ссылкой\",\n copied: \"Скопировано\",\n joined: \"Пришли по вашему коду\",\n rewards: \"Награды за друзей\",\n friends: \"друг|друга|друзей\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n enterCode: \"Ввести код друга\",\n enterHint: \"Друг дал вам код? Введите его — подарок получите вы оба.\",\n youGet: \"Вы получите\",\n activate: \"Активировать\",\n badCode: \"Код — это 8 букв, например WDJB-MJHT\",\n activated: \"Код активирован!\",\n youCameBy: \"Вы пришли по коду друга\",\n inviteText: \"Заходи в игру! Мой код:\",\n});\n"
|
|
47
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Invite\",\n disabled: \"Invites are turned off in this game.\",\n yourCode: \"Your invite code\",\n copyCode: \"Copy code\",\n share: \"Share link\",\n copied: \"Copied\",\n joined: \"Friends who came by your code\",\n rewards: \"Rewards for friends\",\n friends: \"friend|friends\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n enterCode: \"Enter a friend's code\",\n enterHint: \"Got a code from a friend? Enter it and you both get a gift.\",\n youGet: \"You get\",\n activate: \"Activate\",\n badCode: \"A code is 8 letters, like WDJB-MJHT\",\n activated: \"Code activated!\",\n youCameBy: \"You joined by a friend's code\",\n inviteText: \"Join me in the game! My code:\",\n pendingFriends: \"Still meeting the conditions\",\n qualifyHint:\n \"Your friend's invite counts once you meet the game's conditions.\",\n qualifyCheck: \"Check\",\n qualifyDone: \"Done! Your invite counts now.\",\n qualifyNotYet: \"Not yet: some conditions are left.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Пригласить\",\n disabled: \"Приглашения в этой игре выключены.\",\n yourCode: \"Ваш код приглашения\",\n copyCode: \"Скопировать код\",\n share: \"Поделиться ссылкой\",\n copied: \"Скопировано\",\n joined: \"Пришли по вашему коду\",\n rewards: \"Награды за друзей\",\n friends: \"друг|друга|друзей\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n enterCode: \"Ввести код друга\",\n enterHint: \"Друг дал вам код? Введите его — подарок получите вы оба.\",\n youGet: \"Вы получите\",\n activate: \"Активировать\",\n badCode: \"Код — это 8 букв, например WDJB-MJHT\",\n activated: \"Код активирован!\",\n youCameBy: \"Вы пришли по коду друга\",\n inviteText: \"Заходи в игру! Мой код:\",\n pendingFriends: \"Ещё выполняют условия\",\n qualifyHint:\n \"Приглашение друга засчитается, когда вы выполните условия игры.\",\n qualifyCheck: \"Проверить\",\n qualifyDone: \"Готово! Приглашение засчитано.\",\n qualifyNotYet: \"Пока нет: остались невыполненные условия.\",\n});\n"
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"path": "index.ts",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "ReferralScreen.tsx",
|
|
63
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type { FeatureScreenProps, ModuleContext } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n TextInput,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function watchReferral(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n let defs: ReferralDefinitions | null = null;\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n });\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <>\n {/* a phone: one column; a tablet or a PC: my code | the rewards for friends */}\n <Columns min={380} gap={12}>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>\n {t(\"joined\")}\n </div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n {me?.SubscribedToUserID ? (\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.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n </div>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n </div>\n </Columns>\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <TextInput\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
|
|
63
|
+
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type { FeatureScreenProps, ModuleContext } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n TextInput,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n /** Invited friends who have not met the title's invite conditions yet. */\n PendingFollowersCount?: number | null;\n /** This player waits for the invite conditions (the inviter does not count them yet). */\n QualificationPending?: boolean | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function watchReferral(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n let defs: ReferralDefinitions | null = null;\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n });\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [checking, setChecking] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n // The invited player's own conditions, the wallet balance included (a normal read skips the network).\n const checkQualification = async () => {\n setChecking(true);\n const res = await client.referral.checkQualification();\n setChecking(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (res.data.QualificationPending) return toast(t(\"qualifyNotYet\"));\n toast(t(\"qualifyDone\"));\n await load();\n };\n\n return (\n <>\n {/* a phone: one column; a tablet or a PC: my code | the rewards for friends */}\n <Columns min={380} gap={12}>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>\n {t(\"joined\")}\n </div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n {Number(me?.PendingFollowersCount ?? 0) > 0 ? (\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.textDim,\n }}\n >\n <div style={{ flex: 1 }}>{t(\"pendingFriends\")}</div>\n <div>{formatAmount(Number(me?.PendingFollowersCount ?? 0))}</div>\n </div>\n ) : null}\n {me?.SubscribedToUserID && me.QualificationPending ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n }}\n >\n <div>{t(\"qualifyHint\")}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n busy={checking}\n onClick={() => void checkQualification()}\n >\n {t(\"qualifyCheck\")}\n </Button>\n </div>\n ) : me?.SubscribedToUserID ? (\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.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n </div>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n </div>\n </Columns>\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <TextInput\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "module.meta.json",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.4"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.6.
|
|
39
|
+
"@idosgames/core": "0.24.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
41
|
+
"@idosgames/react": "0.6.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "fa0f2dfc6084b8c9650f50c14150a8a9e127ccb27f365d5a87e31af126786f11",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.3"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.6.
|
|
35
|
+
"@idosgames/core": "0.24.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
37
|
+
"@idosgames/react": "0.6.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "f2bbbcd4ac394d7b2325749a9c440987e5abe8eb754aff076ed244cc5c29a272",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"version": "0.1.2"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.13.
|
|
41
|
-
"@idosgames/react": "0.6.
|
|
39
|
+
"@idosgames/core": "0.24.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
41
|
+
"@idosgames/react": "0.6.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "fc1055f63f1791b87f8b1ba7b07177a561f7512edc18ca1447597327c58e57ee",
|
|
@@ -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.6.
|
|
39
|
+
"@idosgames/core": "0.24.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
41
|
+
"@idosgames/react": "0.6.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "158824605f43164105e68883333c347fd5110a14b1ccb43c68036db930bbfe5f",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"version": "0.1.3"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.
|
|
34
|
-
"@idosgames/module-sdk": "0.13.
|
|
35
|
-
"@idosgames/react": "0.6.
|
|
33
|
+
"@idosgames/core": "0.24.1",
|
|
34
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
35
|
+
"@idosgames/react": "0.6.1",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
38
|
"contentHash": "23c1996b36806b24d7649583b3bb0bbe07fb5ac0e8c9ae9c5267735ecec7820c",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"version": "0.2.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.13.
|
|
44
|
-
"@idosgames/react": "0.6.
|
|
42
|
+
"@idosgames/core": "0.24.1",
|
|
43
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
44
|
+
"@idosgames/react": "0.6.1",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
47
|
"contentHash": "03c011db581a59f3e98ea8696f113e02021db798e9a871be151d3e65726db2f8",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.3"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.6.
|
|
35
|
+
"@idosgames/core": "0.24.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
37
|
+
"@idosgames/react": "0.6.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "0f604f01a3e78b950a1dd799e80391a40da4321bcc334d24ba5a77094f22ca6c",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"version": "0.1.5"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.13.
|
|
39
|
-
"@idosgames/react": "0.6.
|
|
37
|
+
"@idosgames/core": "0.24.1",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
39
|
+
"@idosgames/react": "0.6.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "2448dc72d4408919336d2f4011a8dd8903a913811c8ef00db2b64c21b77f5b52",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"version": "0.1.2"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@idosgames/core": "0.
|
|
37
|
-
"@idosgames/module-sdk": "0.13.
|
|
38
|
-
"@idosgames/react": "0.6.
|
|
36
|
+
"@idosgames/core": "0.24.1",
|
|
37
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
38
|
+
"@idosgames/react": "0.6.1",
|
|
39
39
|
"react": "19.2.7"
|
|
40
40
|
},
|
|
41
41
|
"contentHash": "c374268f2fc7cfb1fb4a6073cceca7cb0666f85bc2f1c37bc4ba818233207b61",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"version": "0.1.3"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.
|
|
38
|
-
"@idosgames/module-sdk": "0.13.
|
|
39
|
-
"@idosgames/react": "0.6.
|
|
37
|
+
"@idosgames/core": "0.24.1",
|
|
38
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
39
|
+
"@idosgames/react": "0.6.1",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "f30292cdadf0595a434aaae91598c279add865f03c5ae9767d5b4227e9d479ba",
|
|
@@ -31,9 +31,9 @@
|
|
|
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.6.
|
|
34
|
+
"@idosgames/core": "0.24.1",
|
|
35
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
36
|
+
"@idosgames/react": "0.6.1",
|
|
37
37
|
"react": "19.2.7"
|
|
38
38
|
},
|
|
39
39
|
"contentHash": "62f4f39dd5ef0dd3d72a43a5de98cbc1a4d3e1ccc161b08daf2a9f845e7f2652",
|
|
@@ -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.6.
|
|
39
|
+
"@idosgames/core": "0.24.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
41
|
+
"@idosgames/react": "0.6.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
44
|
"contentHash": "e7d48360d17873bd96f35c06a96a69320d2802f688a71e47392cc7dbf2c1d893",
|
|
@@ -36,16 +36,16 @@
|
|
|
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.6.
|
|
39
|
+
"@idosgames/core": "0.24.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
41
|
+
"@idosgames/react": "0.6.1",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "71ea8d0118abd95a4e9426617b2acfd06ce081bdd30ed8864b36cb4a8fe145e6",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
48
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n});\n"
|
|
48
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n currencyName: \"Aspis\",\n creditsConfirmHint:\n \"Confirm the purchase on idosgames.com — the game cannot spend your Aspis without you.\",\n creditsConfirmOpen: \"Confirm on idosgames.com\",\n creditsWaiting: \"Waiting for confirmation…\",\n creditsLogin: \"Sign in with your iDos Games account to pay with Aspis.\",\n creditsNotEnough: \"Not enough Aspis.\",\n creditsTopUp: \"Top up Aspis\",\n creditsDeclined: \"Purchase cancelled.\",\n creditsExpired: \"The request expired — try again.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n currencyName: \"Aspis\",\n creditsConfirmHint:\n \"Подтвердите покупку на idosgames.com — без вас игра не может тратить ваши Aspis.\",\n creditsConfirmOpen: \"Подтвердить на idosgames.com\",\n creditsWaiting: \"Ждём подтверждения…\",\n creditsLogin: \"Войдите через аккаунт iDos Games, чтобы платить Aspis.\",\n creditsNotEnough: \"Не хватает Aspis.\",\n creditsTopUp: \"Пополнить Aspis\",\n creditsDeclined: \"Покупка отменена.\",\n creditsExpired: \"Срок заявки истёк — попробуйте ещё раз.\",\n});\n"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "index.ts",
|
|
@@ -53,15 +53,19 @@
|
|
|
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\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
|
|
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 === \"other\" && line.id === \"PlatformCredits\"),\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",
|
|
60
60
|
"content": "import {\n defineModule,\n type Module,\n type ModuleContext,\n} from \"@idosgames/module-sdk\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: () => t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n watchBadge(ctx);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction watchBadge(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n // A free gift comes back with the next day: look again every few minutes.\n ctx.every(5 * 60_000, () => void client.store.getStorefront().then(update));\n // The badge is counted from the STOREFRONT, and after a purchase the screen re-reads it: the\n // purchase's own state update comes before that, with the old storefront still cached — so the\n // gift just taken kept the badge lit until the next re-read here. Count again on every load.\n const offs = [\n client.on(\"user:anyUpdated\", update),\n client.on(\"store:storefrontLoaded\", update),\n ];\n return () => {\n for (const off of offs) off();\n };\n });\n}\n"
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
"path": "PlatformCurrencyIcon.tsx",
|
|
64
|
+
"content": "import { useId } from \"react\";\n\n/**\n * Aspis icon (Credits in the code) — the platform currency (owner's decision 26.10.2026: a currency has an icon). ⚠ A copy of the\n * dashboard's and the website's drawing (`platform.idosgames.com/components/core/billing/PlatformCurrencyIcon.tsx`,\n * `iDosGames_com/components/v2/credits/PlatformCurrencyIcon.tsx`): change all three, or one currency looks different in\n * the game and on the site where the player confirms the purchase.\n */\nexport function PlatformCurrencyIcon({ size = 18 }: { size?: number }) {\n // A unique gradient id per instance: two icons sharing one id share one gradient.\n const id = useId().replace(/:/g, \"\");\n return (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 24 24\"\n aria-hidden=\"true\"\n style={{ flexShrink: 0 }}\n >\n <defs>\n <linearGradient id={`cg${id}`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n <stop offset=\"0\" stopColor=\"#FFE58A\" />\n <stop offset=\"1\" stopColor=\"#E08E00\" />\n </linearGradient>\n <clipPath id={`cc${id}`}>\n <circle cx=\"12\" cy=\"12\" r=\"9.3\" />\n </clipPath>\n </defs>\n <circle cx=\"12\" cy=\"12\" r=\"11\" fill={`url(#cg${id})`} />\n <circle\n cx=\"12\"\n cy=\"12\"\n r=\"10.1\"\n fill=\"none\"\n stroke=\"#9A5A00\"\n strokeWidth=\"1.7\"\n />\n <path\n d=\"M2.2 20 L12 10.6 L21.8 20\"\n clipPath={`url(#cc${id})`}\n fill=\"none\"\n stroke=\"#9B1C12\"\n strokeWidth=\"3.1\"\n strokeLinecap=\"butt\"\n strokeLinejoin=\"miter\"\n />\n </svg>\n );\n}\n\n/** \"[icon] 100 Aspis\" — the price of an offer sold for Aspis (Credits in the code). */\nexport function CreditsPrice({\n amount,\n label,\n size = 18,\n}: {\n amount: number;\n label: string;\n size?: number;\n}) {\n return (\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6 }}>\n <PlatformCurrencyIcon size={size} />\n {amount} {label}\n </span>\n );\n}\n"
|
|
65
|
+
},
|
|
62
66
|
{
|
|
63
67
|
"path": "StoreScreen.tsx",
|
|
64
|
-
"content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n costLines,\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 earliestRefresh,\n isFree,\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 = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // 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 ) : (\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[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
|
|
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"
|
|
65
69
|
},
|
|
66
70
|
{
|
|
67
71
|
"path": "module.meta.json",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"version": "0.1.3"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.13.
|
|
37
|
-
"@idosgames/react": "0.6.
|
|
35
|
+
"@idosgames/core": "0.24.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
37
|
+
"@idosgames/react": "0.6.1",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "03421dee0e738bed7dbe18114b3609e4fe6fcf830f909827907d785c4bfaaa18",
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@idosgames/characters": "0.1.1",
|
|
58
|
-
"@idosgames/core": "0.
|
|
59
|
-
"@idosgames/module-sdk": "0.13.
|
|
60
|
-
"@idosgames/react": "0.6.
|
|
58
|
+
"@idosgames/core": "0.24.1",
|
|
59
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
60
|
+
"@idosgames/react": "0.6.1",
|
|
61
61
|
"@idosgames/timeline": "0.1.0",
|
|
62
62
|
"react": "19.2.7",
|
|
63
63
|
"three": "0.185.1"
|
|
@@ -85,9 +85,9 @@
|
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
87
|
"@idosgames/characters": "0.1.1",
|
|
88
|
-
"@idosgames/core": "0.
|
|
89
|
-
"@idosgames/module-sdk": "0.13.
|
|
90
|
-
"@idosgames/react": "0.6.
|
|
88
|
+
"@idosgames/core": "0.24.1",
|
|
89
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
90
|
+
"@idosgames/react": "0.6.1",
|
|
91
91
|
"react": "19.2.7",
|
|
92
92
|
"three": "0.185.1"
|
|
93
93
|
},
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"version": "0.1.3"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@idosgames/core": "0.
|
|
43
|
-
"@idosgames/module-sdk": "0.13.
|
|
44
|
-
"@idosgames/react": "0.6.
|
|
42
|
+
"@idosgames/core": "0.24.1",
|
|
43
|
+
"@idosgames/module-sdk": "0.13.1",
|
|
44
|
+
"@idosgames/react": "0.6.1",
|
|
45
45
|
"react": "19.2.7"
|
|
46
46
|
},
|
|
47
47
|
"contentHash": "ba3020258426f2c8b890d16cd169bffb4e96a4fc61d8a312c24d706ec9eac315",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hero-run",
|
|
3
3
|
"description": "Hero Run — the roguelike runner game module (Obsidian Knight's runs with attack and armor types): the campaign of islands and levels, the rage and the choice of one of three cards, the 58 upgrade cards (a trigger and an effect, Obsidian Knight's form), the attack types × armor types table and the armor formula, troops dressed in the gear that gives their types, the hero's types from its gear (item CustomData AttackType / ArmorType), shields as a blue pool, one ultimate, potions, the camp, and the run economy (ENERGY → RUN_TICKET → reward chest through client.currency and client.lootbox). Use it whenever the user wants to add or tune a card, a troop, an island, a boss, the damage table, the energy or the rewards of hero-run — or a roguelike / auto-runner / card-draft / rock-paper-scissors damage game on the iDosGames SDK — even without naming the module.",
|
|
4
|
-
"content": "---\nname: hero-run\ndescription: >-\n Hero Run — the roguelike runner game module (Obsidian Knight's runs with\n attack and armor types): the campaign of islands and levels, the rage and the\n choice of one of three cards, the 58 upgrade cards (a trigger and an effect, Obsidian Knight's form), the\n attack types × armor types table and the armor formula, troops dressed\n in the gear that gives their types, the hero's types from its gear (item\n CustomData AttackType / ArmorType), shields as a blue pool, one ultimate,\n potions, the camp, and the run economy (ENERGY → RUN_TICKET → reward chest\n through client.currency and client.lootbox). Use it whenever the user wants\n to add or tune a card, a troop, an island, a boss, the damage table, the\n energy or the rewards of hero-run — or a roguelike / auto-runner / card-draft\n / rock-paper-scissors damage game on the iDosGames SDK — even without naming\n the module.\n---\n\n# Hero Run\n\nFiles (module `modules/hero-run/src`):\n\n| File | What
|
|
4
|
+
"content": "---\nname: hero-run\ndescription: >-\n Hero Run — the roguelike runner game module (Obsidian Knight's runs with\n attack and armor types): the campaign of islands and levels, the rage and the\n choice of one of three cards, the 58 upgrade cards (a trigger and an effect, Obsidian Knight's form), the\n attack types × armor types table and the armor formula, troops dressed\n in the gear that gives their types, the hero's types from its gear (item\n CustomData AttackType / ArmorType), shields as a blue pool, one ultimate,\n potions, the camp, and the run economy (ENERGY → RUN_TICKET → reward chest\n through client.currency and client.lootbox). Use it whenever the user wants\n to add or tune a card, a troop, an island, a boss, the damage table, the\n energy or the rewards of hero-run — or a roguelike / auto-runner / card-draft\n / rock-paper-scissors damage game on the iDosGames SDK — even without naming\n the module.\n---\n\n# Hero Run\n\nFiles (module `modules/hero-run/src`):\n\n| File | What |\n| ----------------------- | --------------------------------------------------------------------------------------------------- |\n| `game/damage.ts` | `DAMAGE_TABLE` (7 attack × 6 armor), `armorReduction` / `armorFactor`, `dealt`, types from gear |\n| `game/units.ts` | `TROOPS`: what each troop wears and holds (→ its types), its numbers; bosses |\n| `game/campaign.ts` | Islands (`troops`, `bosses`, `introduces`, `scouting`), `levelOf(n)` — a level's road, `scout` |\n| `game/cards.ts` | `CARDS` (58): passives (`apply` → `RunMods`) and triggers (`proc`: trigger, chance / every, effect) |\n| `game/battle.ts` | One run: road, segments, rage thresholds, cards pause, camp/fountain/chest, ultimate, potions, end |\n| `game/economy.ts` | Run economy from the title's config: energy → ticket, chests by tier, XP level |\n| `game/session.ts` | `RunSession`: hero, gear types, progress, start (convert), claim (lootbox open) |\n| `game/heroGear.ts` | The hero's attack / armor type and shield from its look and its items' CustomData |\n| `game/enemyLooks.ts` | A troop's look = its gear + a face from its id |\n| `phaser/BattleScene.ts` | Draws the run (no rules); number colour by the table (green — strong, grey — weak) |\n| `ui/*` | Map + scouting (`MapPanel`), run bar, card pick / camp / end popups, HUD with the rage bar |\n\n## Rules that are easy to break\n\n- **The ultimate goes once a run.** Ready from the start, kills do not charge it; only a card brings\n it back, by a chance at an action (`ultOnKill`, `ultOnLevel` → `restoreUlt`, event `ultBack`). Auto\n keeps it for the boss, a crowd or low health (`ultWorthIt`).\n- **No health regen.** Health comes back only from potions, the camp, fountains and the life cards.\n The sheet's eight numbers are Obsidian Knight's: damage, attack speed, crit chance, crit multiplier |\n defense (armor's share off a blow), counter (chance), dodge, lifesteal (chance, `LIFESTEAL_SHARE`).\n A new card effect that is a number goes into `HeroNumber`; any other effect goes into `effectLines`\n (the \"Info\" popup).\n- **The hero's stats are not sold.** Between runs they grow from gear (item levels) and rank on the\n Heroes screen, in a run from the cards. Do not bring back a gold Enhance ladder — it outgrows the\n cards. The demo world's stats have no `PriceOptions`, and the `character` system shows no upgrade for\n a priceless stat. The run's sheet (`Battle.heroNumbers`, `ui/RunStats.tsx`) shows each number as the\n hero came and with the cards.\n\n- **Damage goes through `dealt()` only.** Table first, then the armor value. `spells` ignore the armor\n value. Do not subtract armor flatly.\n- **A troop's types come from its gear** (`troopAttack` / `troopArmor`). Change the gear and the picture\n changes with the types. Name `attackType` / `armorType` on the troop only when the gear would lie\n (bosses — hero / hero, the warlock — chaos).\n- **New types come in island by island** (`introduces`). The first island stays casual: unarmored and\n light troops, normal and pierce attacks, no scouting. A test checks every introduced type is first met\n on its island. Every new type needs a tip text `tip.{kind}.{type}` in `i18n.ts` (EN and RU).\n- **A card is data, in Obsidian Knight's form:** a trigger and an effect, or a passive number.\n - Give it a rarity (common / rare / legendary), `island`, `stacks` and an `icon` (`ui/cardIcons.tsx`).\n - Write the RU and EN text with markup: `**trigger**`, `[g]number[/g]`, `[o]effect[/o]`.\n - A passive number is `apply(m)` changing `RunMods`; a triggered effect is\n `proc: { trigger, chance | every, effect }`.\n - A new effect kind goes into `Effect` in `cards.ts`, `Battle.apply` and, if it flies, `boltFx` in the\n scene.\n - A card with nothing to build on lists it in `needs` (`counter` / `dodge` / `shield`).\n - Keep 40–60 cards — a test checks it, and that every trigger of the reference is there.\n- **The run economy is the server's.** Energy and tickets go through `client.currency.convert`, the reward\n through `client.lootbox.open` with the ticket.\n - Never grant rewards from the client.\n - Lootbox ids: `run_chest_defeat` / `run_chest_victory` / `run_chest_treasure`, priced 1 `RUN_TICKET`.\n - The player's first `FIRST_CHESTS` (3) chests are the starter copies `run_chest_*_first`: the same\n contents plus a sure potion. The engine has no \"first N openings\" rule, so the client counts the\n opened chests in `hero_run_progress.chests`. A title without the `_first` chests opens the usual ones.\n - Conversions: `ENERGY → RUN_TICKET` (Manual rate = 1 / energy per ticket, MinAmount = MaxAmount),\n `POTION → XP`, `GEMS → XP` (camp feast).\n - Without the ENERGY → RUN_TICKET conversion the game plays free.\n- **The demo world is the engine's** (`iDos_Games_Engine/Tests/.../HeroRunStarterFixtures.cs` →\n `IDOS_UPDATE_SEEDS=1 dotnet test --filter StarterSeedExport`). Never edit `title-seed.json` by hand.\n\n## Recipes\n\n- **A new troop.** Add an entry to `TROOPS`, using fantasy-pack parts (`top/plate_armor`,\n `weapon/warhammer`, `offhand/tower_shield` …), then list it in an island's `troops`.\n- **A new island loop step.** Add an entry to `ISLANDS` in `campaign.ts` and a theme in `themes.ts`\n (the backdrop paints it). Add the tips of its `introduces`.\n- **Tuning.** `BASE_HP`, `BASE_ATTACK`, `POWER_GROWTH` (the level curve), `RAGE_THRESHOLDS`,\n `RARITY_WEIGHT`, `SHIELD_SHARE`, `CAMP_*` / `FOUNTAIN_*`, `ULTS`.\n- **Checking the balance.** `game/sim.ts` plays the real `Battle` headless on a title config (a\n `title-seed.json` or the title's sections).\n - `simulateRun` — one run.\n - `simulateCampaign` — a player who runs, opens chests as the server does (`RewardMultiplier`,\n `PityRules`), farms when stuck and upgrades with gold.\n - In the SDK `npm run balance -w @idosgames/mod-hero-run` prints the full report.\n - Rerun it after changing a number above, the chests, the gear prices or the cards. A level that\n takes dozens of tries, or a card far above the others, shows there first.\n- **Testing a run.** Use `new Battle({ level, stats, seed })` in `game.test.ts`. For a hand-made road use\n the test helper `road([...segments])`. Take cards in the loop (`takeFirst`). The stand pane runs at\n about 1 fps — fast-forward with `scene.update(0, 250)` from the console.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|