@idosgames/mcp 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +7 -7
  4. package/registry/index.json +437 -110
  5. package/registry/modules/ai.json +65 -0
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +66 -0
  8. package/registry/modules/character.json +4 -5
  9. package/registry/modules/chat.json +4 -5
  10. package/registry/modules/collections.json +69 -0
  11. package/registry/modules/coop-events.json +67 -0
  12. package/registry/modules/craft.json +4 -5
  13. package/registry/modules/deals.json +4 -5
  14. package/registry/modules/events.json +8 -13
  15. package/registry/modules/friends.json +5 -5
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +15 -18
  18. package/registry/modules/invite.json +7 -7
  19. package/registry/modules/leaderboard.json +4 -5
  20. package/registry/modules/lootboxes.json +67 -0
  21. package/registry/modules/mailbox.json +71 -0
  22. package/registry/modules/marketplace.json +4 -5
  23. package/registry/modules/mini-game.json +2 -2
  24. package/registry/modules/multiplayer.json +74 -0
  25. package/registry/modules/premium.json +67 -0
  26. package/registry/modules/profile.json +9 -10
  27. package/registry/modules/pvp.json +4 -5
  28. package/registry/modules/quests.json +69 -0
  29. package/registry/modules/rewards.json +15 -21
  30. package/registry/modules/season.json +8 -13
  31. package/registry/modules/store.json +4 -5
  32. package/registry/modules/tutorials.json +4 -5
  33. package/registry/modules/voxelcraft.json +15 -11
  34. package/registry/modules/workshop.json +4 -5
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/idosgames-compose-modules.json +2 -2
  37. package/registry/skills/idosgames-getting-started.json +1 -1
  38. package/registry/skills/idosgames-module-contract.json +2 -2
  39. package/registry/skills/idosgames-ui-kit.json +2 -2
  40. package/registry/skills/mailbox-system.json +6 -0
  41. package/registry/skills/multiplayer-realtime.json +2 -2
  42. package/registry/skills/workshop-system.json +1 -1
@@ -8,14 +8,13 @@
8
8
  "catalog": {
9
9
  "type": "feature",
10
10
  "inBase": true,
11
- "summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.",
12
- "description": "A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorials is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.",
11
+ "summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, tutorials, log out and account deletion.",
12
+ "description": "A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more). Buttons: Tutorials (when tutorials is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.",
13
13
  "provides": [
14
14
  "player name and ID",
15
15
  "rename popup",
16
16
  "sound, volume and fewer animations",
17
17
  "language picker",
18
- "analytics consent",
19
18
  "log out",
20
19
  "account deletion with confirmation"
21
20
  ],
@@ -36,20 +35,20 @@
36
35
  "version": "0.1.0"
37
36
  },
38
37
  "dependencies": {
39
- "@idosgames/core": "0.19.0",
40
- "@idosgames/module-sdk": "0.6.0",
38
+ "@idosgames/core": "0.19.1",
39
+ "@idosgames/module-sdk": "0.7.0",
41
40
  "@idosgames/react": "0.3.0",
42
41
  "react": "19.2.7"
43
42
  },
44
- "contentHash": "13df00ce0a01385a46bf73d7b50c6308526dbd3d5e1fb585e00b16df388d677d",
43
+ "contentHash": "6749dba8bcb4b647632c9b8e05959b591040b1bac707f4c52788d32a0fdbd924",
45
44
  "files": [
46
45
  {
47
46
  "path": "i18n.ts",
48
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Profile\",\n playerID: \"Player ID\",\n copied: \"Copied\",\n rename: \"Change name\",\n newName: \"New name\",\n save: \"Save\",\n nameShort: \"At least 3 characters\",\n nameLong: \"At most 24 characters\",\n renamed: \"Name changed\",\n settings: \"Settings\",\n sound: \"Sound\",\n volume: \"Volume\",\n motion: \"Fewer animations\",\n language: \"Language\",\n analytics: \"Share anonymous usage data\",\n tutorials: \"Tutorials\",\n logout: \"Log out\",\n logoutAsk:\n \"Log out of this device? A guest account is lost unless it is linked.\",\n delete: \"Delete account\",\n deleteAsk:\n \"This deletes your progress in this game for good. Type the word to confirm:\",\n deleteWord: \"DELETE\",\n deleted: \"Account deleted\",\n on: \"On\",\n off: \"Off\",\n guest: \"Guest\",\n wallets: \"Wallets\",\n unlink: \"Unlink\",\n unlinkTitle: \"Unlink the wallet?\",\n unlinkAsk:\n \"The wallet will be taken off this account. For 7 days it cannot be linked to another account; you can link it back here at any time. If the game is played with a wallet, it closes until you link one.\",\n unlinked: \"Wallet unlinked\",\n unlinkLogin: \"You sign in with this wallet — it cannot be unlinked.\",\n unlinkTooMany:\n \"Too many wallets unlinked this week. Try again in a few days.\",\n unlinkMissing: \"This wallet is no longer linked.\",\n loginWallet: \"Sign-in wallet\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Профиль\",\n playerID: \"ID игрока\",\n copied: \"Скопировано\",\n rename: \"Сменить имя\",\n newName: \"Новое имя\",\n save: \"Сохранить\",\n nameShort: \"Минимум 3 символа\",\n nameLong: \"Максимум 24 символа\",\n renamed: \"Имя изменено\",\n settings: \"Настройки\",\n sound: \"Звук\",\n volume: \"Громкость\",\n motion: \"Меньше анимации\",\n language: \"Язык\",\n analytics: \"Делиться анонимной статистикой\",\n tutorials: \"Обучение\",\n logout: \"Выйти\",\n logoutAsk:\n \"Выйти на этом устройстве? Гостевой аккаунт без привязки будет потерян.\",\n delete: \"Удалить аккаунт\",\n deleteAsk:\n \"Весь ваш прогресс в этой игре будет удалён навсегда. Чтобы подтвердить, введите слово:\",\n deleteWord: \"УДАЛИТЬ\",\n deleted: \"Аккаунт удалён\",\n on: \"Вкл\",\n off: \"Выкл\",\n guest: \"Гость\",\n wallets: \"Кошельки\",\n unlink: \"Отвязать\",\n unlinkTitle: \"Отвязать кошелёк?\",\n unlinkAsk:\n \"Кошелёк будет снят с этого аккаунта. 7 дней его нельзя привязать к другому аккаунту, а к этому — можно вернуть в любой момент. Если в игру играют с кошельком, она закроется, пока вы не привяжете другой.\",\n unlinked: \"Кошелёк отвязан\",\n unlinkLogin: \"Вы входите этим кошельком — его нельзя отвязать.\",\n unlinkTooMany:\n \"На этой неделе отвязано слишком много кошельков. Попробуйте через несколько дней.\",\n unlinkMissing: \"Этот кошелёк уже не привязан.\",\n loginWallet: \"Кошелёк входа\",\n});\n"
47
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Profile\",\n playerID: \"Player ID\",\n copied: \"Copied\",\n rename: \"Change name\",\n newName: \"New name\",\n save: \"Save\",\n nameShort: \"At least 3 characters\",\n nameLong: \"At most 24 characters\",\n renamed: \"Name changed\",\n settings: \"Settings\",\n sound: \"Sound\",\n volume: \"Volume\",\n motion: \"Fewer animations\",\n language: \"Language\",\n tutorials: \"Tutorials\",\n logout: \"Log out\",\n logoutAsk:\n \"Log out of this device? A guest account is lost unless it is linked.\",\n delete: \"Delete account\",\n deleteAsk:\n \"This deletes your progress in this game for good. Type the word to confirm:\",\n deleteWord: \"DELETE\",\n deleted: \"Account deleted\",\n on: \"On\",\n off: \"Off\",\n guest: \"Guest\",\n wallets: \"Wallets\",\n unlink: \"Unlink\",\n unlinkTitle: \"Unlink the wallet?\",\n unlinkAsk:\n \"The wallet will be taken off this account. For 7 days it cannot be linked to another account; you can link it back here at any time. If the game is played with a wallet, it closes until you link one.\",\n unlinked: \"Wallet unlinked\",\n unlinkLogin: \"You sign in with this wallet — it cannot be unlinked.\",\n unlinkTooMany:\n \"Too many wallets unlinked this week. Try again in a few days.\",\n unlinkMissing: \"This wallet is no longer linked.\",\n loginWallet: \"Sign-in wallet\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Профиль\",\n playerID: \"ID игрока\",\n copied: \"Скопировано\",\n rename: \"Сменить имя\",\n newName: \"Новое имя\",\n save: \"Сохранить\",\n nameShort: \"Минимум 3 символа\",\n nameLong: \"Максимум 24 символа\",\n renamed: \"Имя изменено\",\n settings: \"Настройки\",\n sound: \"Звук\",\n volume: \"Громкость\",\n motion: \"Меньше анимации\",\n language: \"Язык\",\n tutorials: \"Обучение\",\n logout: \"Выйти\",\n logoutAsk:\n \"Выйти на этом устройстве? Гостевой аккаунт без привязки будет потерян.\",\n delete: \"Удалить аккаунт\",\n deleteAsk:\n \"Весь ваш прогресс в этой игре будет удалён навсегда. Чтобы подтвердить, введите слово:\",\n deleteWord: \"УДАЛИТЬ\",\n deleted: \"Аккаунт удалён\",\n on: \"Вкл\",\n off: \"Выкл\",\n guest: \"Гость\",\n wallets: \"Кошельки\",\n unlink: \"Отвязать\",\n unlinkTitle: \"Отвязать кошелёк?\",\n unlinkAsk:\n \"Кошелёк будет снят с этого аккаунта. 7 дней его нельзя привязать к другому аккаунту, а к этому — можно вернуть в любой момент. Если в игру играют с кошельком, она закроется, пока вы не привяжете другой.\",\n unlinked: \"Кошелёк отвязан\",\n unlinkLogin: \"Вы входите этим кошельком — его нельзя отвязать.\",\n unlinkTooMany:\n \"На этой неделе отвязано слишком много кошельков. Попробуйте через несколько дней.\",\n unlinkMissing: \"Этот кошелёк уже не привязан.\",\n loginWallet: \"Кошелёк входа\",\n});\n"
49
48
  },
50
49
  {
51
50
  "path": "index.ts",
52
- "content": "// @idosgames/mod-profile — Profile for any game: name, ID, language, sound and motion settings, analytics consent, log out and account deletion.\n\nexport { profileModule } from \"./module\";\n"
51
+ "content": "// @idosgames/mod-profile — Profile for any game: name, ID, language, sound and motion settings, log out and account deletion.\n\nexport { profileModule } from \"./module\";\n"
53
52
  },
54
53
  {
55
54
  "path": "model.ts",
@@ -61,11 +60,11 @@
61
60
  },
62
61
  {
63
62
  "path": "ProfileScreen.tsx",
64
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type { PlayAccessWallet } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n Icon,\n Popup,\n Tabs,\n errorText,\n outlined,\n panel,\n usePlayerPrefs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Profile and settings (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization):\n// the name and ID, sound, volume and motion (kept on this device), the game's language, analytics\n// consent, tutorials, linked wallets, logging out and deleting the account. Renaming, unlinking a\n// wallet and deleting are popups.\n\nexport function makeProfileScreen(features: FeatureRegistry) {\n return function ProfileScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [prefs, setPrefs] = usePlayerPrefs();\n const [name, setName] = useState<string | null>(null);\n const [renaming, setRenaming] = useState(false);\n const [ask, setAsk] = useState<\"logout\" | \"delete\" | null>(null);\n const [locale, setLocale] = useState(client.localization.locale ?? \"\");\n const [analytics, setAnalytics] = useState(client.analytics.isEnabled);\n const id = client.auth.context?.userID ?? \"\";\n const shown = name ?? state?.PublicData?.Username ?? t(\"guest\");\n const languages = languageChoices(client.localization.locales);\n\n const switchLanguage = async (next: string) => {\n const res = await client.localization.setLocale(next);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setLocale(res.data);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n maxWidth: 560,\n margin: \"0 auto\",\n width: \"100%\",\n }}\n >\n <div\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <div\n style={{\n width: 84,\n height: 84,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"3px solid rgba(255,255,255,.7)\",\n ...outlined,\n fontSize: 36,\n }}\n >\n {(shown.trim()[0] ?? \"?\").toUpperCase()}\n </div>\n <div style={{ ...outlined, fontSize: 20 }}>{shown}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setRenaming(true)}\n data-tutorial-anchor=\"profile:rename\"\n >\n <Icon glyph=\"edit\" size={16} /> {t(\"rename\")}\n </Button>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n maxWidth: \"100%\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"playerID\")}\n </span>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 12,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {id}\n </span>\n <button\n type=\"button\"\n aria-label={t(\"playerID\")}\n onClick={() =>\n void navigator.clipboard\n ?.writeText(id)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n padding: 2,\n }}\n >\n <Icon glyph=\"copy\" size={16} />\n </button>\n </div>\n </div>\n\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"settings\")}\n </div>\n <Toggle\n glyph={prefs.muted ? \"mute\" : \"sound\"}\n label={t(\"sound\")}\n value={!prefs.muted}\n onChange={(on) => {\n setPrefs({ muted: !on });\n if (on) kit.play(\"click\");\n }}\n />\n <div\n style={{ display: \"grid\", gap: 4, opacity: prefs.muted ? 0.5 : 1 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"volume\")}</span>\n <span>{volumePercent(prefs.volume)}%</span>\n </div>\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n disabled={prefs.muted}\n value={volumePercent(prefs.volume)}\n onChange={(e) =>\n setPrefs({ volume: Number(e.target.value) / 100 })\n }\n onPointerUp={() => kit.play(\"coin\")}\n style={{ width: \"100%\", accentColor: v.gold }}\n />\n </div>\n <Toggle\n glyph=\"flame\"\n label={t(\"motion\")}\n value={prefs.reducedMotion}\n onChange={(on) => setPrefs({ reducedMotion: on })}\n />\n <Toggle\n glyph=\"globe\"\n label={t(\"analytics\")}\n value={analytics}\n onChange={(on) => {\n client.analytics.setEnabled(on);\n setAnalytics(client.analytics.isEnabled);\n }}\n />\n {languages.length > 1 ? (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"language\")}</div>\n <Tabs\n tabs={languages.map((l) => ({ id: l.id, label: l.name }))}\n value={\n languages.find((l) => locale.startsWith(l.id))?.id ??\n languages[0]!.id\n }\n onChange={(l) => void switchLanguage(l)}\n />\n </div>\n ) : null}\n </div>\n\n <WalletsSection />\n\n {features.get(\"tutorials\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"tutorials\")}>\n <Icon glyph=\"bulb\" size={18} /> {t(\"tutorials\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={() => setAsk(\"logout\")}>\n <Icon glyph=\"logout\" size={18} /> {t(\"logout\")}\n </Button>\n <Button tone=\"red\" onClick={() => setAsk(\"delete\")}>\n <Icon glyph=\"trash\" size={18} /> {t(\"delete\")}\n </Button>\n\n {renaming ? (\n <RenamePopup\n current={shown}\n onClose={() => setRenaming(false)}\n onDone={(n) => {\n setName(n);\n setRenaming(false);\n }}\n />\n ) : null}\n {ask === \"logout\" ? (\n <ConfirmPopup\n title={t(\"logout\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => {\n client.auth.logout();\n location.reload();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"logoutAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {ask === \"delete\" ? <DeletePopup onClose={() => setAsk(null)} /> : null}\n </div>\n );\n };\n}\n\nfunction Toggle({\n glyph,\n label,\n value,\n onChange,\n}: {\n glyph: \"sound\" | \"mute\" | \"flame\" | \"globe\";\n label: string;\n value: boolean;\n onChange: (on: boolean) => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={value}\n onClick={() => onChange(!value)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n background: \"none\",\n border: \"none\",\n color: v.text,\n cursor: \"pointer\",\n padding: 0,\n textAlign: \"left\",\n }}\n >\n <Icon glyph={glyph} size={24} />\n <span style={{ flex: 1, ...outlined, fontSize: 14 }}>{label}</span>\n <span\n style={{\n width: 52,\n height: 30,\n borderRadius: 15,\n background: value ? v.green : \"rgba(255,255,255,.2)\",\n position: \"relative\",\n transition: \"background .2s\",\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: 3,\n left: value ? 25 : 3,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n background: \"#fff\",\n transition: \"left .2s\",\n boxShadow: \"0 2px 4px rgba(0,0,0,.3)\",\n }}\n />\n </span>\n </button>\n );\n}\n\n/**\n * Wallets linked by signature, each with \"Unlink\" (`client.auth.unlinkWallet`). Hidden while there are\n * none. The wallet an account signs in with has no button: the server refuses it (`CanUnlink`).\n */\nfunction WalletsSection(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [wallets, setWallets] = useState<PlayAccessWallet[]>([]);\n const [asking, setAsking] = useState<PlayAccessWallet | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.blockchain.getPlayAccess();\n if (res.ok) setWallets(res.data.LinkedWallets ?? []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n // The popup shows the spinner and closes itself when this resolves.\n const unlink = async (wallet: PlayAccessWallet) => {\n const res = await client.auth.unlinkWallet(wallet.NetworkID ?? \"\");\n if (!res.ok) {\n const known = unlinkRefusal(res.error);\n toast(known ? t(known) : errorText(res.error), \"error\");\n } else toast(t(\"unlinked\"), \"success\");\n await load();\n };\n\n if (wallets.length === 0) return null;\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"wallets\")}\n </div>\n {wallets.map((w) => (\n <div\n key={`${w.NetworkID}:${w.Address}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n minWidth: 0,\n }}\n >\n <Icon glyph=\"link\" size={22} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 13,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {shortAddress(w.Address)}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {(w.NetworkID ?? \"\").toUpperCase()}\n {w.CanUnlink === false ? ` · ${t(\"loginWallet\")}` : \"\"}\n </span>\n </div>\n {w.CanUnlink !== false ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setAsking(w)}>\n {t(\"unlink\")}\n </Button>\n ) : null}\n </div>\n ))}\n {asking ? (\n <ConfirmPopup\n title={t(\"unlinkTitle\")}\n confirmLabel={t(\"unlink\")}\n tone=\"red\"\n onClose={() => setAsking(null)}\n onConfirm={() => unlink(asking)}\n >\n <div\n style={{\n fontFamily: \"monospace\",\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {shortAddress(asking.Address)}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n lineHeight: 1.4,\n }}\n >\n {t(\"unlinkAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction RenamePopup({\n current,\n onClose,\n onDone,\n}: {\n current: string;\n onClose: () => void;\n onDone: (name: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [value, setValue] = useState(current);\n const [busy, setBusy] = useState(false);\n const problem = nameProblem(value);\n const save = async () => {\n if (problem) return;\n setBusy(true);\n const res = await client.user.changeUsername(value.trim());\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"renamed\"), \"success\");\n // The SDK does not patch the cached name — the screen shows the server's answer.\n onDone(res.data.Username ?? value.trim());\n };\n return (\n <Popup title={t(\"rename\")} onClose={onClose} width={400}>\n <input\n value={value}\n autoFocus\n maxLength={40}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void save()}\n placeholder={t(\"newName\")}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n }}\n />\n {problem ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {problem === \"short\" ? t(\"nameShort\") : t(\"nameLong\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!!problem}\n busy={busy}\n onClick={() => void save()}\n >\n {t(\"save\")}\n </Button>\n </Popup>\n );\n}\n\nfunction DeletePopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [typed, setTyped] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const word = t(\"deleteWord\");\n const remove = async () => {\n setBusy(true);\n const res = await client.user.deleteUserAccount();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"deleted\"));\n client.auth.logout();\n location.reload();\n };\n return (\n <Popup title={t(\"delete\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteAsk\")} <span style={{ color: v.red }}>{word}</span>\n </div>\n <input\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n placeholder={word}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n textAlign: \"center\",\n }}\n />\n <Button\n tone=\"red\"\n disabled={!deleteUnlocked(typed, word)}\n busy={busy}\n onClick={() => void remove()}\n >\n {t(\"delete\")}\n </Button>\n </Popup>\n );\n}\n"
63
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type { PlayAccessWallet } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n Icon,\n Popup,\n Tabs,\n errorText,\n outlined,\n panel,\n usePlayerPrefs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Profile and settings (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization):\n// the name and ID, sound, volume and motion (kept on this device), the game's language, tutorials,\n// linked wallets, logging out and deleting the account. Renaming, unlinking a wallet and deleting are\n// popups. No analytics switch (owner's decision 29.09.2026): anonymous analytics stays on as the SDK\n// ships it (client.analytics).\n\nexport function makeProfileScreen(features: FeatureRegistry) {\n return function ProfileScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [prefs, setPrefs] = usePlayerPrefs();\n const [name, setName] = useState<string | null>(null);\n const [renaming, setRenaming] = useState(false);\n const [ask, setAsk] = useState<\"logout\" | \"delete\" | null>(null);\n const [locale, setLocale] = useState(client.localization.locale ?? \"\");\n const id = client.auth.context?.userID ?? \"\";\n const shown = name ?? state?.PublicData?.Username ?? t(\"guest\");\n const languages = languageChoices(client.localization.locales);\n\n const switchLanguage = async (next: string) => {\n const res = await client.localization.setLocale(next);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setLocale(res.data);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n maxWidth: 560,\n margin: \"0 auto\",\n width: \"100%\",\n }}\n >\n <div\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <div\n style={{\n width: 84,\n height: 84,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"3px solid rgba(255,255,255,.7)\",\n ...outlined,\n fontSize: 36,\n }}\n >\n {(shown.trim()[0] ?? \"?\").toUpperCase()}\n </div>\n <div style={{ ...outlined, fontSize: 20 }}>{shown}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setRenaming(true)}\n data-tutorial-anchor=\"profile:rename\"\n >\n <Icon glyph=\"edit\" size={16} /> {t(\"rename\")}\n </Button>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n maxWidth: \"100%\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"playerID\")}\n </span>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 12,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {id}\n </span>\n <button\n type=\"button\"\n aria-label={t(\"playerID\")}\n onClick={() =>\n void navigator.clipboard\n ?.writeText(id)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n padding: 2,\n }}\n >\n <Icon glyph=\"copy\" size={16} />\n </button>\n </div>\n </div>\n\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"settings\")}\n </div>\n <Toggle\n glyph={prefs.muted ? \"mute\" : \"sound\"}\n label={t(\"sound\")}\n value={!prefs.muted}\n onChange={(on) => {\n setPrefs({ muted: !on });\n if (on) kit.play(\"click\");\n }}\n />\n <div\n style={{ display: \"grid\", gap: 4, opacity: prefs.muted ? 0.5 : 1 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"volume\")}</span>\n <span>{volumePercent(prefs.volume)}%</span>\n </div>\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n disabled={prefs.muted}\n value={volumePercent(prefs.volume)}\n onChange={(e) =>\n setPrefs({ volume: Number(e.target.value) / 100 })\n }\n onPointerUp={() => kit.play(\"coin\")}\n style={{ width: \"100%\", accentColor: v.gold }}\n />\n </div>\n <Toggle\n glyph=\"flame\"\n label={t(\"motion\")}\n value={prefs.reducedMotion}\n onChange={(on) => setPrefs({ reducedMotion: on })}\n />\n {languages.length > 1 ? (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"language\")}</div>\n <Tabs\n tabs={languages.map((l) => ({ id: l.id, label: l.name }))}\n value={\n languages.find((l) => locale.startsWith(l.id))?.id ??\n languages[0]!.id\n }\n onChange={(l) => void switchLanguage(l)}\n />\n </div>\n ) : null}\n </div>\n\n <WalletsSection />\n\n {features.get(\"tutorials\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"tutorials\")}>\n <Icon glyph=\"bulb\" size={18} /> {t(\"tutorials\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={() => setAsk(\"logout\")}>\n <Icon glyph=\"logout\" size={18} /> {t(\"logout\")}\n </Button>\n <Button tone=\"red\" onClick={() => setAsk(\"delete\")}>\n <Icon glyph=\"trash\" size={18} /> {t(\"delete\")}\n </Button>\n\n {renaming ? (\n <RenamePopup\n current={shown}\n onClose={() => setRenaming(false)}\n onDone={(n) => {\n setName(n);\n setRenaming(false);\n }}\n />\n ) : null}\n {ask === \"logout\" ? (\n <ConfirmPopup\n title={t(\"logout\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => {\n client.auth.logout();\n location.reload();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"logoutAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {ask === \"delete\" ? <DeletePopup onClose={() => setAsk(null)} /> : null}\n </div>\n );\n };\n}\n\nfunction Toggle({\n glyph,\n label,\n value,\n onChange,\n}: {\n glyph: \"sound\" | \"mute\" | \"flame\" | \"globe\";\n label: string;\n value: boolean;\n onChange: (on: boolean) => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={value}\n onClick={() => onChange(!value)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n background: \"none\",\n border: \"none\",\n color: v.text,\n cursor: \"pointer\",\n padding: 0,\n textAlign: \"left\",\n }}\n >\n <Icon glyph={glyph} size={24} />\n <span style={{ flex: 1, ...outlined, fontSize: 14 }}>{label}</span>\n <span\n style={{\n width: 52,\n height: 30,\n borderRadius: 15,\n background: value ? v.green : \"rgba(255,255,255,.2)\",\n position: \"relative\",\n transition: \"background .2s\",\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: 3,\n left: value ? 25 : 3,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n background: \"#fff\",\n transition: \"left .2s\",\n boxShadow: \"0 2px 4px rgba(0,0,0,.3)\",\n }}\n />\n </span>\n </button>\n );\n}\n\n/**\n * Wallets linked by signature, each with \"Unlink\" (`client.auth.unlinkWallet`). Hidden while there are\n * none. The wallet an account signs in with has no button: the server refuses it (`CanUnlink`).\n */\nfunction WalletsSection(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [wallets, setWallets] = useState<PlayAccessWallet[]>([]);\n const [asking, setAsking] = useState<PlayAccessWallet | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.blockchain.getPlayAccess();\n if (res.ok) setWallets(res.data.LinkedWallets ?? []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n // The popup shows the spinner and closes itself when this resolves.\n const unlink = async (wallet: PlayAccessWallet) => {\n const res = await client.auth.unlinkWallet(wallet.NetworkID ?? \"\");\n if (!res.ok) {\n const known = unlinkRefusal(res.error);\n toast(known ? t(known) : errorText(res.error), \"error\");\n } else toast(t(\"unlinked\"), \"success\");\n await load();\n };\n\n if (wallets.length === 0) return null;\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"wallets\")}\n </div>\n {wallets.map((w) => (\n <div\n key={`${w.NetworkID}:${w.Address}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n minWidth: 0,\n }}\n >\n <Icon glyph=\"link\" size={22} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 13,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {shortAddress(w.Address)}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {(w.NetworkID ?? \"\").toUpperCase()}\n {w.CanUnlink === false ? ` · ${t(\"loginWallet\")}` : \"\"}\n </span>\n </div>\n {w.CanUnlink !== false ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setAsking(w)}>\n {t(\"unlink\")}\n </Button>\n ) : null}\n </div>\n ))}\n {asking ? (\n <ConfirmPopup\n title={t(\"unlinkTitle\")}\n confirmLabel={t(\"unlink\")}\n tone=\"red\"\n onClose={() => setAsking(null)}\n onConfirm={() => unlink(asking)}\n >\n <div\n style={{\n fontFamily: \"monospace\",\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {shortAddress(asking.Address)}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n lineHeight: 1.4,\n }}\n >\n {t(\"unlinkAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction RenamePopup({\n current,\n onClose,\n onDone,\n}: {\n current: string;\n onClose: () => void;\n onDone: (name: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [value, setValue] = useState(current);\n const [busy, setBusy] = useState(false);\n const problem = nameProblem(value);\n const save = async () => {\n if (problem) return;\n setBusy(true);\n const res = await client.user.changeUsername(value.trim());\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"renamed\"), \"success\");\n // The SDK does not patch the cached name — the screen shows the server's answer.\n onDone(res.data.Username ?? value.trim());\n };\n return (\n <Popup title={t(\"rename\")} onClose={onClose} width={400}>\n <input\n value={value}\n autoFocus\n maxLength={40}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void save()}\n placeholder={t(\"newName\")}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n }}\n />\n {problem ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {problem === \"short\" ? t(\"nameShort\") : t(\"nameLong\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!!problem}\n busy={busy}\n onClick={() => void save()}\n >\n {t(\"save\")}\n </Button>\n </Popup>\n );\n}\n\nfunction DeletePopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [typed, setTyped] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const word = t(\"deleteWord\");\n const remove = async () => {\n setBusy(true);\n const res = await client.user.deleteUserAccount();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"deleted\"));\n client.auth.logout();\n location.reload();\n };\n return (\n <Popup title={t(\"delete\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteAsk\")} <span style={{ color: v.red }}>{word}</span>\n </div>\n <input\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n placeholder={word}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n textAlign: \"center\",\n }}\n />\n <Button\n tone=\"red\"\n disabled={!deleteUnlocked(typed, word)}\n busy={busy}\n onClick={() => void remove()}\n >\n {t(\"delete\")}\n </Button>\n </Popup>\n );\n}\n"
65
64
  },
66
65
  {
67
66
  "path": "module.meta.json",
68
- "content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorials is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"analytics consent\",\n \"log out\",\n \"account deletion with confirmation\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
67
+ "content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, tutorials, log out and account deletion.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more). Buttons: Tutorials (when tutorials is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"log out\",\n \"account deletion with confirmation\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
69
68
  }
70
69
  ]
71
70
  }
@@ -7,7 +7,6 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
- "inBase": true,
11
10
  "summary": "PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.",
12
11
  "description": "A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.",
13
12
  "provides": [
@@ -34,12 +33,12 @@
34
33
  "version": "0.1.0"
35
34
  },
36
35
  "dependencies": {
37
- "@idosgames/core": "0.19.0",
38
- "@idosgames/module-sdk": "0.6.0",
36
+ "@idosgames/core": "0.19.1",
37
+ "@idosgames/module-sdk": "0.7.0",
39
38
  "@idosgames/react": "0.3.0",
40
39
  "react": "19.2.7"
41
40
  },
42
- "contentHash": "57db9149531e3285b2a843ae6a4aa181be750a067e0e142a0c2792920d1f0a36",
41
+ "contentHash": "bafe16c7215f36a5ef659c24f879c07b02fdb5d7e7cedc1cdc4ef996e687879c",
43
42
  "files": [
44
43
  {
45
44
  "path": "i18n.ts",
@@ -63,7 +62,7 @@
63
62
  },
64
63
  {
65
64
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
65
+ "content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
67
66
  }
68
67
  ]
69
68
  }
@@ -0,0 +1,69 @@
1
+ {
2
+ "id": "quests",
3
+ "meta": {
4
+ "name": "Quests",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Quests for any game: quests by cycle with details, claim all and the quest points track.",
12
+ "description": "A ready quests screen (Unity Runtime/UI/Quests) for ANY game, built on the @idosgames/react/ui kit and client.quest. The title's quests: cycles as tabs (daily, weekly, achievements) with the timer to the next cycle, progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'quests' feature; its badge counts finished quests. Ships title-seed.json: four daily quests.",
13
+ "provides": [
14
+ "quests by cycle with progress",
15
+ "quest details popup",
16
+ "claim all finished quests",
17
+ "quest points track with milestone chests",
18
+ "badge for finished quests",
19
+ "demo daily quests"
20
+ ],
21
+ "tags": [
22
+ "quests",
23
+ "missions",
24
+ "achievements",
25
+ "daily",
26
+ "ui",
27
+ "screen",
28
+ "events"
29
+ ],
30
+ "author": {
31
+ "name": "iDos Games",
32
+ "url": "https://idosgames.com"
33
+ },
34
+ "version": "0.1.0"
35
+ },
36
+ "dependencies": {
37
+ "@idosgames/core": "0.19.1",
38
+ "@idosgames/module-sdk": "0.7.0",
39
+ "@idosgames/react": "0.3.0",
40
+ "react": "19.2.7"
41
+ },
42
+ "contentHash": "7498596d1c779ccadfa57e24b0023652e46c97f5c8657a4ac95ca15b6c8dcc27",
43
+ "files": [
44
+ {
45
+ "path": "i18n.ts",
46
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Quests\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n quests: \"Quests\",\n permanent: \"Achievements\",\n noQuests: \"No quests. Add them in LiveOps → Quests.\",\n questsReset: \"New quests in\",\n objectives: \"Goals\",\n reward: \"Reward\",\n points: \"Quest points\",\n done: \"Done\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Задания\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n quests: \"Задания\",\n permanent: \"Достижения\",\n noQuests: \"Заданий нет. Добавьте их в LiveOps → Quests.\",\n questsReset: \"Новые задания через\",\n objectives: \"Цели\",\n reward: \"Награда\",\n points: \"Очки заданий\",\n done: \"Выполнено\",\n});\n"
47
+ },
48
+ {
49
+ "path": "index.ts",
50
+ "content": "// @idosgames/mod-quests — Quests for any game: quests by cycle (daily, weekly, achievements) with details, claim all and the quest points track.\n\nexport { questsModule } from \"./module\";\n"
51
+ },
52
+ {
53
+ "path": "model.ts",
54
+ "content": "// Pure helpers of the quests screen: quest rows with the player's progress, the cycle tabs and the\n// quest points track. No React, no client calls — covered by model.test.ts against the module's own\n// demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport { grantLines, mergeLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the quests screen: finished quests and track rewards waiting. */\nexport function questsBadge(rows: QuestRow[], trackReady: number): number {\n return rows.filter((r) => r.status === \"Completed\").length + trackReady;\n}\n\n/** Whether the title has quests — until it does, the lobby hides the tab. */\nexport function hasTitleData(quest: QuestSection | undefined): boolean {\n return Object.keys(quest?.Quests ?? {}).length > 0;\n}\n"
55
+ },
56
+ {
57
+ "path": "module.ts",
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { QuestsScreen, makeQuestsWatcher } from \"./QuestsScreen\";\nimport { hasTitleData, type QuestSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests as a feature of the game: cycles as tabs, the quest points track, claim all. The watcher\n// keeps the badge (finished quests) — only on a title with quests.\nexport const questsModule: Module = defineModule({\n id: \"quests\",\n meta: { name: \"Quests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"quests\",\n label: t(\"title\"),\n icon: \"quest\",\n group: \"events\",\n order: 41,\n primary: true,\n available: false,\n Screen: QuestsScreen,\n });\n const enabled = hasTitleData(\n configSection<QuestSection>(ctx.client, \"Quest\"),\n );\n ctx.features.setAvailable(\n \"quests\",\n enabled,\n \"no quests on the title (Quest)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeQuestsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"quests\" }),\n describeActions: {},\n });\n },\n});\n"
59
+ },
60
+ {
61
+ "path": "QuestsScreen.tsx",
62
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantedBy,\n mergeLines,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableRewards,\n questRows,\n questTabs,\n questsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Quests (Unity Runtime/UI/Quests): the title's quests by cycle — daily, weekly, achievements — with\n// the quest points track on top. A quest's goals and reward open in a popup; \"Claim all\" collects\n// every finished quest.\n\nexport function QuestsScreen(_: FeatureScreenProps): ReactNode {\n return <Quests />;\n}\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = current ? questState?.Cycles?.[current]?.CycleEndUtc : null;\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/**\n * Keeps the badge of the quests screen (finished quests) from the cached state. Registered only on\n * a title with quests, so a title without them makes no quest request on start.\n */\nexport function makeQuestsWatcher(features: FeatureRegistry) {\n return function QuestsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void client.quest.getUserQuestState();\n }, [client]);\n useEffect(() => {\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"quests\", questsBadge(rows, 0));\n }, [client, state]);\n return null;\n };\n}\n"
63
+ },
64
+ {
65
+ "path": "module.meta.json",
66
+ "content": "{\n \"id\": \"quests\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Quests for any game: quests by cycle with details, claim all and the quest points track.\",\n \"description\": \"A ready quests screen (Unity Runtime/UI/Quests) for ANY game, built on the @idosgames/react/ui kit and client.quest. The title's quests: cycles as tabs (daily, weekly, achievements) with the timer to the next cycle, progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'quests' feature; its badge counts finished quests. Ships title-seed.json: four daily quests.\",\n \"provides\": [\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for finished quests\",\n \"demo daily quests\"\n ],\n \"tags\": [\n \"quests\",\n \"missions\",\n \"achievements\",\n \"daily\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
67
+ }
68
+ ]
69
+ }
@@ -7,25 +7,19 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
- "inBase": true,
11
- "summary": "Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.",
12
- "description": "A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.",
10
+ "summary": "Daily rewards for any game: a login calendar that greets the player once per session.",
11
+ "description": "A ready daily rewards screen for ANY game, built on the @idosgames/react/ui kit and client.reward. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project). Registers the 'rewards' feature; its badge shows the daily bonus waiting. Quests are the quests system. Ships title-seed.json: a 7-day calendar.",
13
12
  "provides": [
14
13
  "daily login rewards calendar",
15
14
  "daily bonus popup on entry",
16
- "quests by cycle with progress",
17
- "quest details popup",
18
- "claim all finished quests",
19
- "quest points track with milestone chests",
20
- "badge for waiting rewards",
21
- "demo daily calendar and quests"
15
+ "badge for the waiting daily bonus",
16
+ "demo daily calendar"
22
17
  ],
23
18
  "tags": [
24
19
  "daily-rewards",
25
20
  "calendar",
26
- "quests",
27
- "missions",
28
- "achievements",
21
+ "login",
22
+ "retention",
29
23
  "ui",
30
24
  "screen",
31
25
  "events"
@@ -37,36 +31,36 @@
37
31
  "version": "0.1.0"
38
32
  },
39
33
  "dependencies": {
40
- "@idosgames/core": "0.19.0",
41
- "@idosgames/module-sdk": "0.6.0",
34
+ "@idosgames/core": "0.19.1",
35
+ "@idosgames/module-sdk": "0.7.0",
42
36
  "@idosgames/react": "0.3.0",
43
37
  "react": "19.2.7"
44
38
  },
45
- "contentHash": "f7f8672832798aa56674aa1a5705fac1a036a8dd7cd6febfd3a930bd953b5b41",
39
+ "contentHash": "0f3066cc534f0865a232a8883b4374fc88751e4220f151c58dc75b9b0f1abf9b",
46
40
  "files": [
47
41
  {
48
42
  "path": "i18n.ts",
49
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Rewards\",\n daily: \"Daily rewards\",\n day: \"Day\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n comeBackTomorrow: \"Come back tomorrow for the next reward\",\n noCalendar: \"No daily calendar. Add one in LiveOps → Rewards.\",\n quests: \"Quests\",\n permanent: \"Achievements\",\n noQuests: \"No quests. Add them in LiveOps → Quests.\",\n questsReset: \"New quests in\",\n details: \"Quest\",\n objectives: \"Goals\",\n reward: \"Reward\",\n points: \"Quest points\",\n done: \"Done\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Награды\",\n daily: \"Ежедневные награды\",\n day: \"День\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n comeBackTomorrow: \"Возвращайтесь завтра за следующей наградой\",\n noCalendar: \"Ежедневного календаря нет. Добавьте его в LiveOps → Rewards.\",\n quests: \"Задания\",\n permanent: \"Достижения\",\n noQuests: \"Заданий нет. Добавьте их в LiveOps → Quests.\",\n questsReset: \"Новые задания через\",\n details: \"Задание\",\n objectives: \"Цели\",\n reward: \"Награда\",\n points: \"Очки заданий\",\n done: \"Выполнено\",\n});\n"
43
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Rewards\",\n daily: \"Daily rewards\",\n day: \"Day\",\n claim: \"Claim\",\n comeBackTomorrow: \"Come back tomorrow for the next reward\",\n noCalendar: \"No daily calendar. Add one in LiveOps → Rewards.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Награды\",\n daily: \"Ежедневные награды\",\n day: \"День\",\n claim: \"Забрать\",\n comeBackTomorrow: \"Возвращайтесь завтра за следующей наградой\",\n noCalendar: \"Ежедневного календаря нет. Добавьте его в LiveOps → Rewards.\",\n});\n"
50
44
  },
51
45
  {
52
46
  "path": "index.ts",
53
- "content": "// @idosgames/mod-rewards — Rewards screen for any game: the daily login calendar that greets the player, quests by cycle with details and the quest points track.\n\nexport { rewardsModule } from \"./module\";\n"
47
+ "content": "// @idosgames/mod-rewards — Daily rewards for any game: the login calendar that greets the player once per session.\n\nexport { rewardsModule } from \"./module\";\n"
54
48
  },
55
49
  {
56
50
  "path": "model.ts",
57
- "content": "// Pure helpers of the rewards screen: the daily calendar arithmetic, quest rows and the quest points\n// track. No React, no client calls — covered by model.test.ts against the module's own demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n grantLines,\n mergeLines,\n type Layout,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface DailyDayView {\n DayNumber?: number;\n Rewards?: ResourceGrant | null;\n IsMilestone?: boolean;\n}\n\nexport interface DailyCalendarView {\n CalendarID?: string;\n DisplayName?: string | null;\n Days?: DailyDayView[] | null;\n IsLooping?: boolean;\n ClaimMode?: string;\n ClaimCooldownSeconds?: number;\n}\n\nexport interface RewardSection {\n DailyCalendars?: Record<string, DailyCalendarView | null> | null;\n}\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardSection | undefined,\n): (DailyCalendarView & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is DailyCalendarView & { CalendarID: string } =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyDayView; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarView,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the rewards screen: the daily bonus waiting + quests and track rewards ready. */\nexport function rewardsBadge(\n dailyReady: boolean,\n rows: QuestRow[],\n trackReady: number,\n): number {\n return (\n (dailyReady ? 1 : 0) +\n rows.filter((r) => r.status === \"Completed\").length +\n trackReady\n );\n}\n\n/** Whether the title has a daily calendar or quests — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n reward: RewardSection | undefined,\n quest: QuestSection | undefined,\n): boolean {\n return (\n Object.keys(reward?.DailyCalendars ?? {}).length > 0 ||\n Object.keys(quest?.Quests ?? {}).length > 0\n );\n}\n"
51
+ "content": "// Pure helpers of the daily rewards screen: the calendar arithmetic. No React, no client calls —\n// covered by model.test.ts against the module's own demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport type { Layout } from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface DailyDayView {\n DayNumber?: number;\n Rewards?: ResourceGrant | null;\n IsMilestone?: boolean;\n}\n\nexport interface DailyCalendarView {\n CalendarID?: string;\n DisplayName?: string | null;\n Days?: DailyDayView[] | null;\n IsLooping?: boolean;\n ClaimMode?: string;\n ClaimCooldownSeconds?: number;\n}\n\nexport interface RewardSection {\n DailyCalendars?: Record<string, DailyCalendarView | null> | null;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardSection | undefined,\n): (DailyCalendarView & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is DailyCalendarView & { CalendarID: string } =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyDayView; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarView,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n/** Whether the title has a daily calendar — until it does, the lobby hides the tab. */\nexport function hasTitleData(reward: RewardSection | undefined): boolean {\n return Object.keys(reward?.DailyCalendars ?? {}).length > 0;\n}\n"
58
52
  },
59
53
  {
60
54
  "path": "module.ts",
61
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { hasTitleData, type QuestSection, type RewardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Rewards as a feature of the game: the daily calendar and the quests. The always-on watcher greets\n// the player with the daily bonus once per session and keeps the badge.\nexport const rewardsModule: Module = defineModule({\n id: \"rewards\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n available: false,\n Screen: RewardsScreen,\n });\n ctx.features.setAvailable(\n \"rewards\",\n hasTitleData(\n configSection<RewardSection>(ctx.client, \"Reward\"),\n configSection<QuestSection>(ctx.client, \"Quest\"),\n ),\n \"no daily calendar (Reward) and no quests (Quest) on the title\",\n );\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
55
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { hasTitleData, type RewardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards as a feature of the game: the login calendar. The always-on watcher greets the\n// player with the daily bonus once per session and keeps the badge.\nexport const rewardsModule: Module = defineModule({\n id: \"rewards\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n available: false,\n Screen: RewardsScreen,\n });\n const enabled = hasTitleData(\n configSection<RewardSection>(ctx.client, \"Reward\"),\n );\n ctx.features.setAvailable(\n \"rewards\",\n enabled,\n \"no daily calendar on the title (Reward)\",\n );\n // The greeting reads the rewards state — only where there is a calendar (no startup request\n // for a feature the title does not have: the engine's per-player limit answers a burst 429).\n if (enabled)\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
62
56
  },
63
57
  {
64
58
  "path": "RewardsScreen.tsx",
65
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { QuestPointsTrackView } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n mergeLines,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n claimableRewards,\n dailyView,\n lastCardSpan,\n pickCalendar,\n questRows,\n questTabs,\n rewardsBadge,\n trackMilestones,\n type QuestRow,\n type QuestSection,\n type QuestStateView,\n type RewardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Rewards (Unity Alikhan/Dailies + Runtime/UI/Quests): the daily calendar — collected / today /\n// locked cards, the last one a big milestone — and the quests by cycle, with the quest points track\n// on top. A quest's goals and reward open in a popup; \"Claim all\" collects every finished quest.\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const layout = useGameLayout();\n const state = useUserState();\n\n // Fresh state on open: quests refresh their cycle on the server (a new day = new quests).\n useEffect(() => {\n void client.reward.getUserRewardsState();\n }, [client]);\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 18,\n alignItems: \"start\",\n gridTemplateColumns:\n layout === \"desktop\"\n ? \"minmax(0, 1fr) minmax(0, 1fr)\"\n : \"minmax(0, 1fr)\",\n }}\n >\n <DailyCalendar rewardState={state?.Reward ?? null} />\n <Quests />\n </div>\n );\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\ntype RewardState = {\n DailyCalendars?: Record<\n string,\n { CollectedDays?: number; LastClaimAt?: string } | undefined\n >;\n} | null;\n\nexport function DailyCalendar({\n rewardState,\n compact = false,\n onClaimed,\n}: {\n rewardState: RewardState;\n compact?: boolean;\n onClaimed?: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const layout = useGameLayout();\n const kit = useUiKit();\n const stagger = useStagger();\n const calendar = pickCalendar(configSection<RewardSection>(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n void client.quest.getUserQuestState();\n };\n\n return (\n <div>\n {compact ? null : (\n <SectionTitle>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </SectionTitle>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n return (\n <div\n key={day.DayNumber ?? i}\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n borderRadius: 14,\n padding: \"8px 4px\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n position: \"relative\",\n background:\n state === \"collected\"\n ? \"linear-gradient(180deg, #2f6e3a, #1d4a27)\"\n : today\n ? `linear-gradient(180deg, ${v.gold}, ${v.goldDeep})`\n : day.IsMilestone\n ? \"linear-gradient(180deg, #7b4be0, #3f2699)\"\n : \"rgba(0,0,0,.28)\",\n border: today\n ? \"2px solid #fff\"\n : \"2px solid rgba(255,255,255,.08)\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 12 }}>\n {t(\"day\")} {day.DayNumber ?? i + 1}\n </span>\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 36 : 30}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...outlined, fontSize: 12 }}>\n ×{main.amount}\n </span>\n ) : null}\n {state === \"collected\" ? (\n <span style={{ position: \"absolute\", top: -6, right: -6 }}>\n <Icon glyph=\"check\" size={22} />\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nfunction Quests(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const now = useNow();\n const [tracks, setTracks] = useState<Record<string, QuestPointsTrackView>>(\n {},\n );\n const [tab, setTab] = useState<string | null>(null);\n const [detail, setDetail] = useState<QuestRow | null>(null);\n const [busyAll, setBusyAll] = useState(false);\n\n const load = useCallback(async () => {\n const res = await client.quest.getUserQuestState();\n if (res.ok)\n setTracks(\n (res.data.PointsTracks ?? {}) as Record<string, QuestPointsTrackView>,\n );\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const section = configSection<QuestSection>(client, \"Quest\");\n const questState = (state?.Quest ?? null) as QuestStateView | null;\n const rows = questRows(section, questState);\n if (rows.length === 0)\n return <EmptyState glyph=\"quest\" text={t(\"noQuests\")} />;\n\n const tabs = questTabs(section, rows);\n const current =\n tab !== null && tabs.some((x) => x.id === tab) ? tab : (tabs[0]?.id ?? \"\");\n const shown = rows.filter((r) => (r.cycleID ?? \"\") === current);\n const cycleEnd = current ? questState?.Cycles?.[current]?.CycleEndUtc : null;\n const ready = shown.filter((r) => r.status === \"Completed\");\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.quest.claimQuestRewardsBatch(\n ready.map((r) => ({ QuestID: r.questID, CycleID: r.cycleID })),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n mergeLines(\n res.data.flatMap((item) => (item.Success ? grantedBy(item.Data) : [])),\n ),\n );\n void load();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <SectionTitle\n right={<Timer at={cycleEnd} now={now} label={t(\"questsReset\")} />}\n >\n {t(\"quests\")}\n </SectionTitle>\n <Tabs\n tabs={tabs.map((x) => ({\n id: x.id,\n label: x.id ? catalog.localize(x.name) : t(\"permanent\"),\n badge: rows.filter(\n (r) => (r.cycleID ?? \"\") === x.id && r.status === \"Completed\",\n ).length,\n }))}\n value={current}\n onChange={setTab}\n />\n {current ? (\n <PointsTrack\n cycleID={current}\n section={section}\n track={tracks[current]}\n onClaimed={load}\n />\n ) : null}\n {ready.length >= 2 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")}{\" \"}\n <ResourceList lines={claimableRewards(ready)} size={18} />\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n {shown.map((row, i) => (\n <Card\n key={`${row.cycleID ?? \"\"}:${row.questID}`}\n highlight={row.status === \"Completed\"}\n onClick={() => setDetail(row)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n opacity: row.status === \"Claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"quest\" size={38} />\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </div>\n <ProgressBar\n value={row.progress}\n max={row.target}\n tone={row.status === \"Completed\" ? \"gold\" : \"green\"}\n />\n <ResourceList lines={row.reward} />\n </div>\n {row.status === \"Claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : row.status === \"Completed\" ? (\n <Icon glyph=\"gift\" size={34} />\n ) : (\n <span />\n )}\n </Card>\n ))}\n </div>\n {detail ? (\n <QuestDetail\n row={\n rows.find(\n (r) =>\n r.questID === detail.questID && r.cycleID === detail.cycleID,\n ) ?? detail\n }\n onClose={() => setDetail(null)}\n onClaimed={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction QuestDetail({\n row,\n onClose,\n onClaimed,\n}: {\n row: QuestRow;\n onClose: () => void;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup title={catalog.localize(row.name)} onClose={onClose}>\n {row.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(row.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"objectives\")}\n </div>\n {row.objectives.map((o) => (\n <div key={o.id} style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 14,\n }}\n >\n <span>{catalog.localize(o.name)}</span>\n {o.done ? <Icon glyph=\"check\" size={20} /> : null}\n </div>\n <ProgressBar\n value={o.progress}\n max={o.target}\n tone={o.done ? \"gold\" : \"green\"}\n />\n </div>\n ))}\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={row.reward} size={26} gap={14} />\n </div>\n {row.status === \"Completed\" ? (\n <ClaimQuest row={row} onClaimed={onClaimed} />\n ) : row.status === \"Claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n {t(\"done\")}\n </div>\n ) : null}\n </Popup>\n );\n}\n\nfunction ClaimQuest({\n row,\n onClaimed,\n}: {\n row: QuestRow;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n return (\n <div ref={ref}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n style={{ width: \"100%\" }}\n onClick={() => {\n setBusy(true);\n void client.quest\n .claimQuestReward(row.questID, row.cycleID)\n .then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : row.reward);\n void onClaimed();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction PointsTrack({\n cycleID,\n section,\n track,\n onClaimed,\n}: {\n cycleID: string;\n section: QuestSection | undefined;\n track: QuestPointsTrackView | undefined;\n onClaimed: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [open, setOpen] = useState<string | null>(null);\n const cycle = section?.Cycles?.[cycleID];\n const points = Number(track?.PointsCurrent ?? track?.PointsTotalEarned ?? 0);\n const milestones = trackMilestones(\n cycle,\n points,\n track?.ClaimedPointMilestoneIDs ?? [],\n );\n if (milestones.length === 0) return null;\n const max = milestones[milestones.length - 1]?.required ?? 1;\n const picked = milestones.find((m) => m.id === open);\n\n return (\n <div style={{ ...panel, padding: \"12px 14px\", display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"points\")}</span>\n <span style={{ color: v.gold }}>\n {points} / {max}\n </span>\n </div>\n <div style={{ position: \"relative\", height: 44 }}>\n <div style={{ position: \"absolute\", left: 0, right: 0, top: 16 }}>\n <ProgressBar\n value={Math.min(points, max)}\n max={max}\n tone=\"gold\"\n label=\"\"\n height={12}\n />\n </div>\n {milestones.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n className={`idos-press ${m.state === \"ready\" ? \"idos-bounce\" : \"\"}`}\n onClick={() => setOpen(m.id)}\n aria-label={catalog.localize(m.name)}\n style={{\n position: \"absolute\",\n left: `calc(${(m.required / max) * 100}% - 20px)`,\n top: 0,\n width: 40,\n height: 40,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n opacity: m.state === \"claimed\" ? 0.55 : 1,\n }}\n >\n <Icon glyph={m.state === \"claimed\" ? \"check\" : \"chest\"} size={36} />\n </button>\n ))}\n </div>\n {picked ? (\n <Popup\n title={catalog.localize(picked.name)}\n onClose={() => setOpen(null)}\n width={360}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <Icon glyph=\"chest\" size={72} />\n <ResourceList lines={picked.reward} size={26} gap={14} />\n <ProgressBar\n value={Math.min(points, picked.required)}\n max={picked.required}\n tone=\"gold\"\n />\n </div>\n {picked.state === \"ready\" ? (\n <ClaimMilestone\n onClaim={async () => {\n const res = await client.quest.claimMilestoneReward(\n cycleID,\n picked.id,\n );\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n celebrate(\n grantedBy(res.data).length > 0\n ? grantedBy(res.data)\n : picked.reward,\n );\n await onClaimed();\n return true;\n }}\n />\n ) : null}\n </Popup>\n ) : null}\n </div>\n );\n}\n\nfunction ClaimMilestone({\n onClaim,\n}: {\n onClaim: () => Promise<boolean>;\n}): ReactNode {\n const close = usePopupClose();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void onClaim().then((ok) => {\n setBusy(false);\n if (ok) close();\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n\n// ── Always-on: the daily bonus greets the player, and the badge ─────────────────────────────\n\n/**\n * Like the Unity DailyBonusAutoOpener: a claimable daily reward pops up once per session. Also keeps\n * the badge of the rewards screen (daily bonus + finished quests + track rewards).\n */\nexport function makeRewardsWatcher(features: FeatureRegistry) {\n return function RewardsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [popup, setPopup] = useState(false);\n const shownOnce = useRef(false);\n\n useEffect(() => {\n let cancelled = false;\n void client.reward.getUserRewardsState().then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n if (!calendar) return;\n const s = res.data.Rewards?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n void client.quest.getUserQuestState();\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change.\n useEffect(() => {\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n const rows = questRows(\n configSection<QuestSection>(client, \"Quest\"),\n (state?.Quest ?? null) as QuestStateView | null,\n );\n features.setBadge(\"rewards\", rewardsBadge(dailyReady, rows, 0));\n }, [client, state]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
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 EmptyState,\n Icon,\n Popup,\n SectionTitle,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport {\n calendarColumns,\n dailyView,\n lastCardSpan,\n pickCalendar,\n type RewardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Daily rewards (Unity Alikhan/Dailies): the login calendar — collected / today / locked cards, the\n// last one a big milestone. Quests are a system of their own (the quests module).\n\nexport function RewardsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n\n // Fresh state on open: the calendar's day may have turned since the last read.\n useEffect(() => {\n void client.reward.getUserRewardsState();\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<RewardSection>(client, \"Reward\"));\n const todayRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n if (!calendar)\n return compact ? null : (\n <EmptyState glyph=\"calendar\" text={t(\"noCalendar\")} />\n );\n\n const view = dailyView(\n calendar,\n rewardState?.DailyCalendars?.[calendar.CalendarID],\n new Date(),\n );\n const columns = calendarColumns(layout, compact, view.days.length);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.reward.claimDailyReward(calendar.CalendarID);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(todayRef.current);\n const today = view.days.find((d) => d.state === \"today\");\n const granted = grantedBy(res.data);\n onClaimed?.();\n celebrate(granted.length > 0 ? granted : grantLines(today?.day.Rewards));\n };\n\n return (\n <div>\n {compact ? null : (\n <SectionTitle>\n {catalog.localize(calendar.DisplayName ?? t(\"daily\"))}\n </SectionTitle>\n )}\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n gap: 8,\n }}\n >\n {view.days.map(({ day, state }, i) => {\n const lines = grantLines(day.Rewards);\n const main = lines[0];\n const span =\n i === view.days.length - 1\n ? lastCardSpan(view.days.length, columns)\n : 1;\n const today = state === \"today\";\n return (\n <div\n key={day.DayNumber ?? i}\n ref={today ? todayRef : undefined}\n className={today ? \"idos-glow\" : undefined}\n style={{\n gridColumn: span > 1 ? `span ${span}` : undefined,\n borderRadius: 14,\n padding: \"8px 4px\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n position: \"relative\",\n background:\n state === \"collected\"\n ? \"linear-gradient(180deg, #2f6e3a, #1d4a27)\"\n : today\n ? `linear-gradient(180deg, ${v.gold}, ${v.goldDeep})`\n : day.IsMilestone\n ? \"linear-gradient(180deg, #7b4be0, #3f2699)\"\n : \"rgba(0,0,0,.28)\",\n border: today\n ? \"2px solid #fff\"\n : \"2px solid rgba(255,255,255,.08)\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 12 }}>\n {t(\"day\")} {day.DayNumber ?? i + 1}\n </span>\n <div\n className={today ? \"idos-bounce\" : undefined}\n style={{ display: \"flex\", gap: 2 }}\n >\n {lines.slice(0, 2).map((line) => (\n <Icon\n key={`${line.kind}:${line.id}`}\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={day.IsMilestone ? 36 : 30}\n />\n ))}\n </div>\n {main ? (\n <span style={{ ...outlined, fontSize: 12 }}>\n ×{main.amount}\n </span>\n ) : null}\n {state === \"collected\" ? (\n <span style={{ position: \"absolute\", top: -6, right: -6 }}>\n <Icon glyph=\"check\" size={22} />\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n {view.canClaim ? (\n <Button\n busy={busy}\n attract\n size=\"lg\"\n onClick={() => void claim()}\n data-tutorial-anchor=\"rewards:daily-claim\"\n >\n <Icon glyph=\"gift\" size={22} /> {t(\"claim\")}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n textAlign: \"center\",\n fontSize: 13,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"comeBackTomorrow\")}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ── 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 void client.reward.getUserRewardsState().then((res) => {\n if (cancelled || !res.ok || shownOnce.current) return;\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n if (!calendar) return;\n const s = res.data.Rewards?.DailyCalendars?.[calendar.CalendarID];\n if (dailyView(calendar, s, new Date()).canClaim) {\n shownOnce.current = true;\n setPopup(true);\n }\n });\n return () => {\n cancelled = true;\n };\n }, [client]);\n\n // Badge from the cached state, recomputed on every change.\n useEffect(() => {\n const calendar = pickCalendar(\n configSection<RewardSection>(client, \"Reward\"),\n );\n const dailyReady = calendar\n ? dailyView(\n calendar,\n (state?.Reward as RewardState)?.DailyCalendars?.[\n calendar.CalendarID\n ],\n new Date(),\n ).canClaim\n : false;\n features.setBadge(\"rewards\", dailyReady ? 1 : 0);\n }, [client, state]);\n\n if (!popup) return null;\n return (\n <Popup\n title={t(\"daily\")}\n onClose={() => setPopup(false)}\n width={440}\n variant=\"center\"\n queued\n >\n <DailyCalendar\n compact\n rewardState={(state?.Reward ?? null) as RewardState}\n onClaimed={() => setPopup(false)}\n />\n </Popup>\n );\n };\n}\n"
66
60
  },
67
61
  {
68
62
  "path": "module.meta.json",
69
- "content": "{\n \"id\": \"rewards\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.\",\n \"description\": \"A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for waiting rewards\",\n \"demo daily calendar and quests\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"quests\",\n \"missions\",\n \"achievements\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
63
+ "content": "{\n \"id\": \"rewards\",\n \"type\": \"feature\",\n \"summary\": \"Daily rewards for any game: a login calendar that greets the player once per session.\",\n \"description\": \"A ready daily rewards screen for ANY game, built on the @idosgames/react/ui kit and client.reward. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project). Registers the 'rewards' feature; its badge shows the daily bonus waiting. Quests are the quests system. Ships title-seed.json: a 7-day calendar.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"badge for the waiting daily bonus\",\n \"demo daily calendar\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"login\",\n \"retention\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
70
64
  }
71
65
  ]
72
66
  }