@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
@@ -0,0 +1,71 @@
1
+ {
2
+ "id": "mailbox",
3
+ "meta": {
4
+ "name": "Mailbox",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.",
12
+ "description": "A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.",
13
+ "provides": [
14
+ "mailbox screen",
15
+ "letters with rewards and claim all",
16
+ "publisher broadcasts in the inbox",
17
+ "gifts to friends",
18
+ "resource transfers between friends",
19
+ "missed rewards of other systems",
20
+ "mailbox badge"
21
+ ],
22
+ "tags": [
23
+ "mailbox",
24
+ "mail",
25
+ "inbox",
26
+ "gifts",
27
+ "rewards",
28
+ "social",
29
+ "ui",
30
+ "screen"
31
+ ],
32
+ "author": {
33
+ "name": "iDos Games",
34
+ "url": "https://idosgames.com"
35
+ },
36
+ "version": "0.1.0"
37
+ },
38
+ "dependencies": {
39
+ "@idosgames/core": "0.19.1",
40
+ "@idosgames/module-sdk": "0.7.0",
41
+ "@idosgames/react": "0.3.0",
42
+ "react": "19.2.7"
43
+ },
44
+ "contentHash": "b5f960cc3809c920a30dfe655c0531fea691236f418d59107daaffa8a9bbcb66",
45
+ "files": [
46
+ {
47
+ "path": "i18n.ts",
48
+ "content": "import { makeT } from \"@idosgames/react/ui\";\nimport type { LetterText } from \"./model\";\n\nconst EN = {\n title: \"Mail\",\n disabled: \"The mailbox is turned off in this game.\",\n tabAll: \"All\",\n tabReward: \"Rewards\",\n tabGift: \"Gifts\",\n tabSystem: \"News\",\n empty: \"No letters yet.\",\n emptyTab: \"Nothing here.\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n expired: \"Expired\",\n expiresIn: \"Expires in\",\n more: \"Show more\",\n deleteRead: \"Delete read\",\n deleteReadAsk:\n \"Delete every read letter? Letters with unclaimed rewards stay.\",\n deleted: \"Deleted\",\n delete: \"Delete\",\n decline: \"Send back\",\n declineAsk: \"Send the resources back to your friend?\",\n declined: \"Sent back\",\n open: \"Open\",\n from: \"From\",\n note: \"Message\",\n waiting: \"Waiting for you\",\n sendGifts: \"Send gifts\",\n gift: \"Gift\",\n transfer: \"Transfer\",\n giftHint:\n \"A gift your friend claims in their mailbox. Once per period for each friend.\",\n transferHint:\n \"Send your own resources. If your friend does not take them in time, they come back.\",\n toEveryone: \"Send to all\",\n sendTo: \"Send\",\n sentToday: \"Sent today\",\n pricePerFriend: \"Price per friend\",\n leftInPeriod: \"Left\",\n resetsIn: \"Resets in\",\n commission:\n \"Commission {p}% — your friend gets less (returned if they decline)\",\n fixedFee: \"Fee\",\n friendGets: \"Your friend gets\",\n noFriends: \"Add friends to send them gifts.\",\n pickFriend: \"Choose a friend\",\n owned: \"You have\",\n amount: \"Amount\",\n notePlaceholder: \"A few words (optional)\",\n transferAsk: \"Send to {name}?\",\n giftSent: \"Gift sent\",\n transferSent: \"Sent! It waits in your friend's mailbox.\",\n nothingToSend: \"Choose what to send.\",\n someFailed: \"Some could not be sent\",\n // Server codes → words.\n errALREADY_CLAIMED: \"Already claimed.\",\n errEXPIRED: \"This letter has expired.\",\n errMESSAGE_NOT_FOUND: \"This letter is gone.\",\n errNOT_FRIENDS: \"You are not friends.\",\n errBLOCKED: \"This player does not accept gifts from you.\",\n errALREADY_SENT: \"Already sent this period.\",\n errSEND_LIMIT: \"The limit for this period is reached. Come back later.\",\n errNOT_ENOUGH_RESOURCES: \"Not enough resources.\",\n errAMOUNT_TOO_SMALL: \"After the commission your friend would get nothing.\",\n errRECIPIENT_LIMIT: \"Your friend got enough gifts today.\",\n errFRIENDSHIP_TOO_NEW: \"You can send resources to a friend a bit later.\",\n errAMOUNT_TOO_LARGE: \"Too much for one transfer.\",\n errRESOURCE_NOT_GIFTABLE: \"This can not be sent.\",\n errTRANSFER_DISABLED: \"Transfers are turned off.\",\n errGIFT_NOT_ALLOWED: \"This gift is not available to you.\",\n errMODULE_REWARD_UNAVAILABLE: \"This reward is no longer available.\",\n errMAILBOX_DISABLED: \"The mailbox is turned off in this game.\",\n // The platform's own letters (templates \"system.*\"), unless the publisher rewrote them.\n sysGiftSubject: \"A gift from {sender}\",\n sysGiftBody: \"Your friend {sender} sent you a gift.\",\n sysTransferSubject: \"{sender} sent you resources\",\n sysTransferBody: \"Claim them — or send them back.\",\n sysTransferReturnSubject: \"Your transfer came back\",\n sysTransferReturnBody:\n \"Your friend did not take it in time — here it is again.\",\n sysLeaderboardSubject: \"Leaderboard reward\",\n sysLeaderboardBody: \"You finished #{rank} in the leaderboard.\",\n sysCoopGrandSubject: \"Team event prize\",\n sysCoopGrandBody: \"Your team finished the event. Here is the grand prize.\",\n sysCoopObjectSubject: \"Team event reward\",\n sysCoopObjectBody: \"Your share of the team's progress.\",\n sysSeasonSubject: \"Season rewards you missed\",\n sysSeasonBody:\n \"The season is over — we kept the rewards you reached (tier {tier}).\",\n sysReferralSubject: \"Reward for friends\",\n sysReferralBody: \"{friends} friends joined by your code.\",\n} as const;\n\ntype Key = keyof typeof EN;\n\nexport const t = makeT<Key>(EN, {\n title: \"Почта\",\n disabled: \"Почта в этой игре выключена.\",\n tabAll: \"Все\",\n tabReward: \"Награды\",\n tabGift: \"Подарки\",\n tabSystem: \"Новости\",\n empty: \"Писем пока нет.\",\n emptyTab: \"Здесь пусто.\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n expired: \"Истекло\",\n expiresIn: \"Истекает через\",\n more: \"Показать ещё\",\n deleteRead: \"Удалить прочитанные\",\n deleteReadAsk:\n \"Удалить все прочитанные письма? Письма с незабранными наградами останутся.\",\n deleted: \"Удалено\",\n delete: \"Удалить\",\n decline: \"Вернуть\",\n declineAsk: \"Вернуть ресурсы другу?\",\n declined: \"Возвращено\",\n open: \"Перейти\",\n from: \"От\",\n note: \"Сообщение\",\n waiting: \"Ждут вас\",\n sendGifts: \"Подарки друзьям\",\n gift: \"Подарок\",\n transfer: \"Передать\",\n giftHint: \"Подарок — друг заберёт его в почте. Раз за период каждому другу.\",\n transferHint:\n \"Отправьте свои ресурсы. Если друг не заберёт их вовремя, они вернутся.\",\n toEveryone: \"Подарить всем\",\n sendTo: \"Отправить\",\n sentToday: \"Сегодня отправлено\",\n pricePerFriend: \"Цена за друга\",\n leftInPeriod: \"Осталось\",\n resetsIn: \"Обновится через\",\n commission:\n \"Комиссия {p}% — друг получит меньше (вернётся, если он откажется)\",\n fixedFee: \"Плата\",\n friendGets: \"Друг получит\",\n noFriends: \"Добавьте друзей, чтобы дарить им подарки.\",\n pickFriend: \"Выберите друга\",\n owned: \"У вас\",\n amount: \"Сколько\",\n notePlaceholder: \"Пара слов (необязательно)\",\n transferAsk: \"Отправить игроку {name}?\",\n giftSent: \"Подарок отправлен\",\n transferSent: \"Отправлено! Ждёт друга в почте.\",\n nothingToSend: \"Выберите, что отправить.\",\n someFailed: \"Часть не отправилась\",\n errALREADY_CLAIMED: \"Уже получено.\",\n errEXPIRED: \"Срок письма истёк.\",\n errMESSAGE_NOT_FOUND: \"Этого письма уже нет.\",\n errNOT_FRIENDS: \"Вы не друзья.\",\n errBLOCKED: \"Этот игрок не принимает от вас подарки.\",\n errALREADY_SENT: \"В этом периоде уже отправлено.\",\n errSEND_LIMIT: \"Лимит на этот период исчерпан. Возвращайтесь позже.\",\n errNOT_ENOUGH_RESOURCES: \"Не хватает ресурсов.\",\n errAMOUNT_TOO_SMALL: \"После комиссии другу ничего не останется.\",\n errRECIPIENT_LIMIT: \"Друг уже получил сегодня достаточно подарков.\",\n errFRIENDSHIP_TOO_NEW:\n \"Передавать ресурсы этому другу можно будет чуть позже.\",\n errAMOUNT_TOO_LARGE: \"Слишком много для одной передачи.\",\n errRESOURCE_NOT_GIFTABLE: \"Это нельзя передать.\",\n errTRANSFER_DISABLED: \"Передача выключена.\",\n errGIFT_NOT_ALLOWED: \"Этот подарок вам недоступен.\",\n errMODULE_REWARD_UNAVAILABLE: \"Эта награда больше недоступна.\",\n errMAILBOX_DISABLED: \"Почта в этой игре выключена.\",\n sysGiftSubject: \"Подарок от {sender}\",\n sysGiftBody: \"Ваш друг {sender} прислал вам подарок.\",\n sysTransferSubject: \"{sender} прислал вам ресурсы\",\n sysTransferBody: \"Заберите их — или верните отправителю.\",\n sysTransferReturnSubject: \"Передача вернулась\",\n sysTransferReturnBody: \"Друг не забрал её вовремя — ресурсы снова у вас.\",\n sysLeaderboardSubject: \"Награда таблицы лидеров\",\n sysLeaderboardBody: \"Вы заняли {rank}-е место в таблице лидеров.\",\n sysCoopGrandSubject: \"Приз командного ивента\",\n sysCoopGrandBody: \"Ваша команда прошла ивент. Вот главный приз.\",\n sysCoopObjectSubject: \"Награда командного ивента\",\n sysCoopObjectBody: \"Ваша доля за прогресс команды.\",\n sysSeasonSubject: \"Незабранные награды сезона\",\n sysSeasonBody:\n \"Сезон закончился — мы сохранили награды, до которых вы дошли (ступень {tier}).\",\n sysReferralSubject: \"Награда за друзей\",\n sysReferralBody: \"По вашему коду пришло друзей: {friends}.\",\n});\n\nconst BUILT_IN: Record<string, [Key, Key]> = {\n \"system.gift\": [\"sysGiftSubject\", \"sysGiftBody\"],\n \"system.transfer\": [\"sysTransferSubject\", \"sysTransferBody\"],\n \"system.transfer_return\": [\n \"sysTransferReturnSubject\",\n \"sysTransferReturnBody\",\n ],\n \"system.leaderboard_reward\": [\"sysLeaderboardSubject\", \"sysLeaderboardBody\"],\n \"system.coop_grand_prize\": [\"sysCoopGrandSubject\", \"sysCoopGrandBody\"],\n \"system.coop_object_reward\": [\"sysCoopObjectSubject\", \"sysCoopObjectBody\"],\n \"system.season_unclaimed\": [\"sysSeasonSubject\", \"sysSeasonBody\"],\n \"system.referral_reward\": [\"sysReferralSubject\", \"sysReferralBody\"],\n};\n\n/** Texts of the platform's own templates (a letter of a publisher's template carries its own). */\nexport function builtInText(templateID: string): LetterText | null {\n const keys = BUILT_IN[templateID];\n return keys ? { subject: t(keys[0]), body: t(keys[1]) } : null;\n}\n\n/** A server code in the player's words; any other error as the kit shows it. */\nexport function mailError(error: string | null | undefined): string | null {\n const code = (error ?? \"\").trim();\n const key = `err${code}` as Key;\n return code && key in EN ? t(key) : null;\n}\n"
49
+ },
50
+ {
51
+ "path": "index.ts",
52
+ "content": "// @idosgames/mod-mailbox — Mailbox for any game: letters with rewards, broadcasts, gifts and transfers between friends, missed rewards and claim all.\n\nexport { mailboxModule } from \"./module\";\n"
53
+ },
54
+ {
55
+ "path": "MailboxScreen.tsx",
56
+ "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n MailboxCategory,\n MailboxDefinitions,\n MailboxGiftStatusResponse,\n MailboxLetter,\n MailboxPendingReward,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n ResourceBadge,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n bundleLines,\n isGlyph,\n outlined,\n pickLanguage,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport {\n afterFee,\n claimableCount,\n expiresSoon,\n feePercent,\n letterState,\n letterText,\n mailBadge,\n mergePages,\n newRequestID,\n transferEntries,\n transferOptions,\n type TransferInventory,\n type TransferOption,\n} from \"./model\";\nimport { builtInText, mailError, t } from \"./i18n\";\n\n// Mailbox (Unity V2/Runtime/UI/Mailbox): letters newest first by category, a letter opened in a\n// popup, \"Claim\" / \"Claim all\", missed rewards of other systems on top, and \"Send gifts\" — a free\n// daily gift to friends and a transfer of own resources. Everything the server decides (expiry,\n// limits, friendship) comes back as its codes; the screen only words them.\n\ntype Tab = \"all\" | \"Reward\" | \"Gift\" | \"System\";\n\n/** What `ctx.features.open(\"mailbox\", args)` understands — the friends system sends it. */\ninterface MailboxArgs {\n friendUserID?: string;\n friendName?: string;\n mode?: \"gift\" | \"transfer\";\n}\n\ninterface Friend {\n UserID: string;\n PublicData?: { Username?: string | null; AvatarUrl?: string | null } | null;\n}\n\nfunction words(error: string | null | undefined): string {\n return mailError(error) ?? errorText(error);\n}\n\nfunction friendName(f: Friend): string {\n const name = f.PublicData?.Username?.trim();\n if (name) return name;\n return f.UserID.length > 10\n ? `${f.UserID.slice(0, 5)}…${f.UserID.slice(-4)}`\n : f.UserID;\n}\n\nconst CATEGORY_GLYPH: Record<MailboxCategory, Glyph> = {\n System: \"bell\",\n Reward: \"chest\",\n Gift: \"gift\",\n Social: \"friends\",\n};\n\nconst MODULE_GLYPH: Record<string, Glyph> = {\n Leaderboard: \"trophy\",\n CoopEvent: \"swords\",\n Season: \"crown\",\n Referral: \"link\",\n};\n\n/** Badge on login and on coming back to the tab; mailbox actions refresh it themselves. */\nexport function makeMailboxWatcher(features: FeatureRegistry) {\n return function MailboxWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n void client.mailbox.getUnreadCount();\n const onVisible = () => {\n if (document.visibilityState === \"visible\")\n void client.mailbox.getUnreadCount();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n return () => {\n off();\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeMailboxScreen(features: FeatureRegistry) {\n return function MailboxScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const defs = configSection<MailboxDefinitions>(client, \"Mailbox\");\n if (defs?.Enabled !== true)\n return <EmptyState glyph=\"gift\" text={t(\"disabled\")} />;\n return (\n <Mailbox\n features={features}\n defs={defs}\n args={(args ?? {}) as MailboxArgs}\n />\n );\n };\n}\n\nfunction Mailbox({\n features,\n defs,\n args,\n}: {\n features: FeatureRegistry;\n defs: MailboxDefinitions;\n args: MailboxArgs;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const now = useNow(30_000);\n const [tab, setTab] = useState<Tab>(\"all\");\n const [letters, setLetters] = useState<MailboxLetter[] | null>(null);\n const [pending, setPending] = useState<MailboxPendingReward[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [loadingMore, setLoadingMore] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [opened, setOpened] = useState<MailboxLetter | null>(null);\n const [askClean, setAskClean] = useState(false);\n const [gifts, setGifts] = useState<MailboxArgs | null>(\n args.friendUserID || args.mode ? args : null,\n );\n\n const category = tab === \"all\" ? undefined : tab;\n // Letters in the language the screen speaks: the game's own locale when it has localization\n // tables, else the kit's language — otherwise a Russian screen showed English letters.\n const language = client.localization.locale ?? pickLanguage();\n const load = useCallback(async () => {\n const res = await client.mailbox.getMessages({ category, language });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n setLetters([]);\n return;\n }\n setLetters(res.data.Messages);\n setPending(res.data.PendingRewards ?? []);\n setCursor(res.data.NextCursor ?? null);\n }, [client, category, language]);\n useEffect(() => {\n setLetters(null);\n void load();\n }, [load]);\n\n const more = async () => {\n if (!cursor) return;\n setLoadingMore(true);\n const res = await client.mailbox.getMessages({\n category,\n cursor,\n language,\n });\n setLoadingMore(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n setLetters((shown) => mergePages(shown ?? [], res.data.Messages));\n setCursor(res.data.NextCursor ?? null);\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.mailbox.claimAll();\n setBusyAll(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n const got = grantedBy(res.data);\n if (got.length > 0) celebrate(got);\n if (res.data.Failed.length > 0)\n toast(words(res.data.Failed[0]?.Error), \"error\");\n await load();\n };\n\n const cleanRead = async () => {\n const res = await client.mailbox.deleteMessages(\"allRead\");\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(t(\"deleted\"));\n await load();\n };\n\n const giftsOn =\n Object.values(defs.Gifts?.Social ?? {}).some(\n (g) => g && g.Enabled !== false,\n ) || defs.Gifts?.Transfer?.Enabled === true;\n const shownPending = tab === \"all\" || tab === \"Reward\" ? pending : [];\n const toClaim = letters ? claimableCount(letters, shownPending, now) : 0;\n const hasRead = (letters ?? []).some(\n (l) => l.ReadAt && letterState(l, now) !== \"claimable\",\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <Tabs<Tab>\n tabs={[\n { id: \"all\", label: t(\"tabAll\") },\n { id: \"Reward\", label: t(\"tabReward\"), glyph: \"chest\" },\n { id: \"Gift\", label: t(\"tabGift\"), glyph: \"gift\" },\n { id: \"System\", label: t(\"tabSystem\"), glyph: \"bell\" },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n {toClaim > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n style={{ flex: 1 }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"mailbox:gifts\"\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"sendGifts\")}\n </Button>\n ) : null}\n </div>\n\n {letters === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n <>\n {shownPending.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"waiting\")}\n </div>\n ) : null}\n {shownPending.map((p, i) => (\n <PendingRow\n key={p.MessageID}\n reward={p}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n {letters.length === 0 && shownPending.length === 0 ? (\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n ) : null}\n {letters.map((l, i) => (\n <LetterRow\n key={l.MessageID}\n letter={l}\n now={now}\n onOpen={() => setOpened(l)}\n onClaimed={load}\n style={stagger(i + shownPending.length)}\n />\n ))}\n {cursor ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={loadingMore}\n onClick={() => void more()}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => setAskClean(true)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </>\n )}\n\n {opened ? (\n <LetterPopup\n letter={opened}\n features={features}\n onClose={() => setOpened(null)}\n onRead={(id, readAt) =>\n setLetters((shown) =>\n (shown ?? []).map((l) =>\n l.MessageID === id ? { ...l, ReadAt: readAt } : l,\n ),\n )\n }\n onChanged={load}\n />\n ) : null}\n {askClean ? (\n <ConfirmPopup\n title={t(\"deleteRead\")}\n tone=\"red\"\n onClose={() => setAskClean(false)}\n onConfirm={cleanRead}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteReadAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {gifts ? (\n <GiftsPopup\n defs={defs}\n initial={gifts}\n onClose={() => setGifts(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction LetterIcon({\n icon,\n fallback,\n size = 34,\n}: {\n icon: string | null | undefined;\n fallback: Glyph;\n size?: number;\n}): ReactNode {\n const glyph =\n icon && (isGlyph(icon) || !/^https?:/i.test(icon)) ? icon : null;\n if (icon && /^https?:/i.test(icon))\n return (\n <img\n src={icon}\n alt=\"\"\n style={{\n width: size,\n height: size,\n borderRadius: 8,\n objectFit: \"cover\",\n }}\n />\n );\n return <Icon glyph={glyph ?? fallback} size={size} />;\n}\n\nfunction LetterRow({\n letter,\n now,\n onOpen,\n onClaimed,\n style,\n}: {\n letter: MailboxLetter;\n now: number;\n onOpen: () => void;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const state = letterState(letter, now);\n const text = letterText(letter, builtInText);\n const unread = !letter.ReadAt;\n const reward = grantLines(letter.Rewards);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n await onClaimed();\n };\n\n return (\n <div\n ref={ref}\n onClick={onOpen}\n className=\"idos-press\"\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n border: state === \"claimable\" ? `2px solid ${v.gold}` : panel.border,\n opacity: state === \"claimed\" || state === \"expired\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <LetterIcon\n icon={letter.Icon}\n fallback={CATEGORY_GLYPH[letter.Category]}\n />\n {unread ? (\n <span\n style={{\n position: \"absolute\",\n top: -2,\n right: -2,\n width: 10,\n height: 10,\n borderRadius: 5,\n background: v.red,\n }}\n />\n ) : null}\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n fontWeight: unread ? 800 : 600,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {text.subject || t(\"title\")}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n {letter.SenderName ? <span>{letter.SenderName}</span> : null}\n <span>{new Date(letter.SentAt).toLocaleDateString()}</span>\n {state === \"claimable\" && expiresSoon(letter, now) ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n {reward.length > 0 ? <ResourceList lines={reward} size={20} /> : null}\n </div>\n {state === \"claimable\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={(e) => {\n e.stopPropagation();\n void claim();\n }}\n >\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : state === \"expired\" ? (\n <Icon glyph=\"clock\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction PendingRow({\n reward,\n onClaimed,\n style,\n}: {\n reward: MailboxPendingReward;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const text = letterText(reward, builtInText);\n const lines = grantLines(reward.Rewards);\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(reward.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : lines);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: `2px solid ${v.gold}`,\n ...style,\n }}\n >\n <Icon glyph={MODULE_GLYPH[reward.Module] ?? \"chest\"} size={34} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{text.subject}</div>\n {text.body ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {text.body}\n </div>\n ) : null}\n {lines.length > 0 ? <ResourceList lines={lines} size={20} /> : null}\n </div>\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction LetterPopup({\n letter,\n features,\n onClose,\n onRead,\n onChanged,\n}: {\n letter: MailboxLetter;\n features: FeatureRegistry;\n onClose: () => void;\n onRead: (id: string, readAt: string) => void;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const now = useNow(30_000);\n const [busy, setBusy] = useState(false);\n const [ask, setAsk] = useState<\"decline\" | null>(null);\n const [claimedNow, setClaimedNow] = useState(false);\n const text = letterText(letter, builtInText);\n const reward = grantLines(letter.Rewards);\n const state = claimedNow ? \"claimed\" : letterState(letter, now);\n\n // Opening a letter reads it — once, and only an unread one.\n useEffect(() => {\n if (letter.ReadAt) return;\n void client.mailbox.readMessages([letter.MessageID]).then((r) => {\n if (r.ok) onRead(letter.MessageID, new Date().toISOString());\n });\n }, [letter.MessageID]);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n setClaimedNow(true);\n await onChanged();\n };\n const remove = async (force: boolean) => {\n const res = await client.mailbox.deleteMessages([letter.MessageID], {\n force,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(force ? t(\"declined\") : t(\"deleted\"));\n await onChanged();\n onClose();\n };\n const link = letter.Link;\n const follow = () => {\n if (link?.FeatureID) {\n onClose();\n features.open(link.FeatureID, link.Args ?? undefined);\n } else if (link?.Url && /^https:\\/\\//i.test(link.Url)) {\n window.open(link.Url, \"_blank\", \"noopener,noreferrer\");\n }\n };\n const canFollow =\n (!!link?.FeatureID && !!features.get(link.FeatureID)?.available) ||\n (!!link?.Url && /^https:\\/\\//i.test(link.Url));\n\n return (\n <Popup title={text.subject || t(\"title\")} onClose={onClose} width={440}>\n {letter.Banner && /^https?:/i.test(letter.Banner) ? (\n <img\n src={letter.Banner}\n alt=\"\"\n style={{\n width: \"100%\",\n borderRadius: 10,\n objectFit: \"cover\",\n maxHeight: 180,\n }}\n />\n ) : null}\n {letter.SenderName ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}: {letter.SenderName} ·{\" \"}\n {new Date(letter.SentAt).toLocaleString()}\n </div>\n ) : null}\n {text.body ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n whiteSpace: \"pre-wrap\",\n lineHeight: 1.4,\n }}\n >\n {text.body}\n </div>\n ) : null}\n {letter.SenderNote ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n fontStyle: \"italic\",\n }}\n >\n «{letter.SenderNote}»\n </div>\n ) : null}\n {reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <ResourceList lines={reward} size={34} gap={12} />\n {state === \"claimable\" && letter.ExpiresAt ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {state === \"claimable\" ? (\n <Button tone=\"green\" attract busy={busy} onClick={() => void claim()}>\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </div>\n ) : state === \"expired\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.textDim }}>\n {t(\"expired\")}\n </div>\n ) : null}\n {canFollow ? (\n <Button tone=\"blue\" onClick={follow}>\n {link?.Label || t(\"open\")}\n </Button>\n ) : null}\n {state === \"claimable\" && letter.IsTransfer ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"decline\")}>\n {t(\"decline\")}\n </Button>\n ) : state !== \"claimable\" ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => void remove(false)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"delete\")}\n </Button>\n ) : null}\n </div>\n {ask ? (\n <ConfirmPopup\n title={t(\"decline\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => remove(true)}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"declineAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n\n// ── Gifts ──────────────────────────────────────────────────────────────────\n\nfunction GiftsPopup({\n defs,\n initial,\n onClose,\n}: {\n defs: MailboxDefinitions;\n initial: MailboxArgs;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const social = Object.entries(defs.Gifts?.Social ?? {}).filter(\n ([, g]) => g && g.Enabled !== false,\n );\n const transferOn = defs.Gifts?.Transfer?.Enabled === true;\n const [mode, setMode] = useState<\"gift\" | \"transfer\">(\n initial.mode === \"transfer\" && transferOn\n ? \"transfer\"\n : social.length > 0\n ? \"gift\"\n : \"transfer\",\n );\n const [friends, setFriends] = useState<Friend[] | null>(null);\n const [status, setStatus] = useState<MailboxGiftStatusResponse | null>(null);\n\n const refresh = useCallback(async () => {\n const s = await client.mailbox.getGiftStatus();\n if (s.ok) setStatus(s.data);\n }, [client]);\n useEffect(() => {\n void client.social.getFriendsList().then((r) => {\n const list = (r.ok ? (r.data.Friends ?? []) : []) as Friend[];\n setFriends(\n [...list].sort((a, b) => friendName(a).localeCompare(friendName(b))),\n );\n });\n void refresh();\n }, [client, refresh]);\n\n return (\n <Popup title={t(\"sendGifts\")} onClose={onClose} width={460}>\n <Tabs<\"gift\" | \"transfer\">\n tabs={[\n ...(social.length > 0\n ? [{ id: \"gift\" as const, label: t(\"gift\"), glyph: \"gift\" }]\n : []),\n ...(transferOn\n ? [{ id: \"transfer\" as const, label: t(\"transfer\"), glyph: \"bag\" }]\n : []),\n ]}\n value={mode}\n onChange={setMode}\n />\n {friends === null ? (\n <Center minHeight={160}>\n <Spinner />\n </Center>\n ) : friends.length === 0 ? (\n <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />\n ) : mode === \"gift\" ? (\n <SocialGifts\n gifts={social}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n />\n ) : (\n <Transfer\n defs={defs}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n onDone={onClose}\n />\n )}\n </Popup>\n );\n}\n\nfunction FriendChip({\n friend,\n selected,\n done,\n onClick,\n}: {\n friend: Friend;\n selected: boolean;\n done?: boolean;\n onClick?: () => void;\n}): ReactNode {\n return (\n <div\n onClick={done ? undefined : onClick}\n className={done ? undefined : \"idos-press\"}\n style={{\n ...panel,\n padding: \"8px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n cursor: done ? \"default\" : \"pointer\",\n border: selected ? `2px solid ${v.gold}` : panel.border,\n opacity: done ? 0.55 : 1,\n }}\n >\n {friend.PublicData?.AvatarUrl ? (\n <img\n src={friend.PublicData.AvatarUrl}\n alt=\"\"\n style={{\n width: 28,\n height: 28,\n borderRadius: 14,\n objectFit: \"cover\",\n }}\n />\n ) : (\n <Icon glyph=\"user\" size={26} />\n )}\n <span\n style={{\n ...outlined,\n fontSize: 14,\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {friendName(friend)}\n </span>\n {done ? (\n <Icon glyph=\"check\" size={18} />\n ) : selected ? (\n <Icon glyph=\"gift\" size={18} />\n ) : null}\n </div>\n );\n}\n\nfunction SocialGifts({\n gifts,\n friends,\n status,\n initialFriend,\n onSent,\n}: {\n gifts: Array<\n [\n string,\n NonNullable<NonNullable<MailboxDefinitions[\"Gifts\"]>[\"Social\"]>[string],\n ]\n >;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [giftID, setGiftID] = useState(gifts[0]?.[0] ?? \"\");\n const [picked, setPicked] = useState<Set<string>>(\n () =>\n new Set(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? [initialFriend]\n : [],\n ),\n );\n const [busy, setBusy] = useState(false);\n const now = useNow(30_000);\n const mine = status?.Social?.[giftID];\n const sentTo = new Set(mine?.SentTo ?? []);\n const open = friends.filter((f) => !sentTo.has(f.UserID));\n const gift = gifts.find(([id]) => id === giftID)?.[1];\n const price = bundleLines(gift?.Cost);\n\n const send = async (ids: string[]) => {\n if (ids.length === 0) return;\n setBusy(true);\n const res = await client.mailbox.sendGift(giftID, ids);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n if (res.data.Sent.length > 0) toast(t(\"giftSent\"), \"success\");\n if (res.data.Failed.length > 0)\n toast(`${t(\"someFailed\")}: ${words(res.data.Failed[0]?.Error)}`, \"error\");\n setPicked(new Set());\n await onSent();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"giftHint\")}\n </div>\n {gifts.length > 1 ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {gifts.map(([id, g]) => (\n <Card\n key={id}\n highlight={id === giftID}\n onClick={() => {\n setGiftID(id);\n setPicked(new Set());\n }}\n style={{ padding: 8 }}\n >\n <ResourceList lines={bundleLines(g?.Grant)} size={24} />\n </Card>\n ))}\n </div>\n ) : gift ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <ResourceList lines={bundleLines(gift.Grant)} size={30} gap={12} />\n </div>\n ) : null}\n {price.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {t(\"pricePerFriend\")}: <ResourceList lines={price} size={18} />\n </div>\n ) : null}\n {mine ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {mine.RemainingInPeriod}\n </span>\n <Timer at={mine.ResetsAt} now={now} label={t(\"resetsIn\")} />\n </div>\n ) : null}\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 280, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n done={sentTo.has(f.UserID)}\n selected={picked.has(f.UserID)}\n onClick={() =>\n setPicked((s) => {\n const next = new Set(s);\n if (next.has(f.UserID)) next.delete(f.UserID);\n else next.add(f.UserID);\n return next;\n })\n }\n />\n ))}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n busy={busy}\n disabled={picked.size === 0}\n onClick={() => void send([...picked])}\n style={{ flex: 1 }}\n >\n {t(\"sendTo\")}\n {picked.size > 0 ? ` · ${picked.size}` : \"\"}\n </Button>\n <Button\n tone=\"gold\"\n attract={open.length > 0}\n busy={busy}\n disabled={open.length === 0}\n onClick={() => void send(open.map((f) => f.UserID))}\n style={{ flex: 1 }}\n >\n {t(\"toEveryone\")}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction Transfer({\n defs,\n friends,\n status,\n initialFriend,\n onSent,\n onDone,\n}: {\n defs: MailboxDefinitions;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const state = useUserState();\n const catalog = useCatalog();\n const [to, setTo] = useState<string | null>(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? initialFriend\n : null,\n );\n const [amounts, setAmounts] = useState<Record<string, number>>({});\n const [note, setNote] = useState(\"\");\n const [ask, setAsk] = useState(false);\n // One key per attempt: a retry after a timeout reuses it, so the server never charges twice.\n const requestID = useRef(newRequestID());\n const options = useMemo(\n () =>\n transferOptions(\n defs.Gifts?.Transfer?.AllowedResources,\n state?.InventoryV2 as TransferInventory | undefined,\n ),\n [defs, state?.InventoryV2],\n );\n const entries = transferEntries(options, amounts);\n const friend = friends.find((f) => f.UserID === to) ?? null;\n const now = useNow(30_000);\n const fee = feePercent(defs.Gifts?.Transfer?.FeePercent);\n const receives = afterFee(entries, fee);\n const fixedFee = bundleLines(defs.Gifts?.Transfer?.FeeCost);\n\n const send = async () => {\n if (!to) return false;\n const res = await client.mailbox.sendTransfer(to, entries, {\n requestID: requestID.current,\n note: note.trim() || undefined,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n requestID.current = newRequestID();\n toast(t(\"transferSent\"), \"success\");\n await onSent();\n onDone();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"transferHint\")}\n </div>\n {status?.Transfer ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {status.Transfer.RemainingInPeriod}\n </span>\n <Timer\n at={status.Transfer.ResetsAt}\n now={now}\n label={t(\"resetsIn\")}\n />\n </div>\n ) : null}\n {fee > 0 || fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {fee > 0 ? (\n <span>{t(\"commission\").replace(\"{p}\", String(fee))}</span>\n ) : null}\n {fixedFee.length > 0 ? (\n <>\n <span>{t(\"fixedFee\")}:</span>\n <ResourceList lines={fixedFee} size={18} />\n </>\n ) : null}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"pickFriend\")}</div>\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 180, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n selected={to === f.UserID}\n onClick={() => setTo(f.UserID)}\n />\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 6 }}>\n {options.map((o) => (\n <TransferRow\n key={o.key}\n option={o}\n value={amounts[o.key] ?? 0}\n name={\n o.kind === \"currency\"\n ? catalog.currencyName(o.id)\n : catalog.itemName(o.id)\n }\n onChange={(n) => setAmounts((a) => ({ ...a, [o.key]: n }))}\n />\n ))}\n </div>\n <input\n value={note}\n onChange={(e) => setNote(e.target.value)}\n maxLength={120}\n placeholder={t(\"notePlaceholder\")}\n style={{\n ...panel,\n padding: \"10px 12px\",\n fontSize: 14,\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n tone=\"green\"\n disabled={!to || entries.length === 0 || receives.length === 0}\n onClick={() =>\n entries.length === 0 ? toast(t(\"nothingToSend\")) : setAsk(true)\n }\n >\n {t(\"sendTo\")}\n </Button>\n {ask && friend ? (\n <ConfirmPopup\n title={t(\"transfer\")}\n tone=\"gold\"\n onClose={() => setAsk(false)}\n onConfirm={send}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"transferAsk\").replace(\"{name}\", friendName(friend))}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: entries })}\n size={28}\n gap={12}\n />\n {fee > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"friendGets\")}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: receives })}\n size={22}\n gap={10}\n />\n </div>\n ) : null}\n {fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"fixedFee\")}: <ResourceList lines={fixedFee} size={18} />\n </div>\n ) : null}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction TransferRow({\n option,\n value,\n name,\n onChange,\n}: {\n option: TransferOption;\n value: number;\n name: string;\n onChange: (n: number) => void;\n}): ReactNode {\n const disabled = option.max <= 0;\n const set = (n: number) =>\n onChange(Math.max(0, Math.min(option.max, Math.floor(n) || 0)));\n return (\n <div\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n opacity: disabled ? 0.5 : 1,\n }}\n >\n <ResourceBadge\n line={{ kind: option.kind, id: option.id, amount: option.owned }}\n size={24}\n />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{name}</div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"owned\")}: {option.owned}\n </div>\n </div>\n <input\n type=\"number\"\n min={0}\n max={option.max}\n disabled={disabled}\n value={value || \"\"}\n placeholder=\"0\"\n onChange={(e) => set(Number(e.target.value))}\n style={{\n ...panel,\n width: 80,\n padding: \"6px 8px\",\n fontSize: 15,\n textAlign: \"center\",\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={disabled}\n onClick={() => set(option.max)}\n >\n max\n </Button>\n </div>\n );\n}\n"
57
+ },
58
+ {
59
+ "path": "model.ts",
60
+ "content": "// Pure helpers of the mailbox screen: which letters can be claimed, what a letter says when the\n// server sends only its template, the badge, and what a player may transfer to a friend. The server\n// owns every rule (expiry, limits, friendship, the transfer white list) — this only decides what the\n// screen shows. Covered by model.test.ts.\n\nimport type {\n MailboxLetter,\n MailboxPendingReward,\n ResourceEntry,\n TransferableResource,\n} from \"@idosgames/core\";\n\n/** Whether the title has the mailbox on (off by default) — until it is, the lobby hides the tab. */\nexport function mailboxEnabled(\n section: { Enabled?: boolean | null } | null | undefined,\n): boolean {\n return section?.Enabled === true;\n}\n\n/**\n * The lobby badge. Unread letters and rewards waiting overlap (a new letter with a gift is both), so\n * the bigger of the two — never their sum.\n */\nexport function mailBadge(\n counters:\n | { UnreadCount?: number | null; UnclaimedCount?: number | null }\n | null\n | undefined,\n): number {\n return Math.max(\n Number(counters?.UnreadCount ?? 0),\n Number(counters?.UnclaimedCount ?? 0),\n );\n}\n\nexport type LetterState = \"claimable\" | \"claimed\" | \"plain\" | \"expired\";\n\nexport function letterState(\n letter: Pick<MailboxLetter, \"HasRewards\" | \"ClaimedAt\" | \"ExpiresAt\">,\n now: number,\n): LetterState {\n if (letter.HasRewards && letter.ClaimedAt) return \"claimed\";\n if (letter.ExpiresAt && Date.parse(letter.ExpiresAt) <= now) return \"expired\";\n return letter.HasRewards ? \"claimable\" : \"plain\";\n}\n\n/** A letter's reward expires within `hours` — the row shows the timer. */\nexport function expiresSoon(\n letter: Pick<MailboxLetter, \"ExpiresAt\">,\n now: number,\n hours = 24,\n): boolean {\n if (!letter.ExpiresAt) return false;\n const left = Date.parse(letter.ExpiresAt) - now;\n return left > 0 && left <= hours * 3_600_000;\n}\n\n/** `{name}` placeholders of a text, filled from the letter's params; unknown ones stay as they are. */\nexport function fillParams(\n text: string,\n params: Record<string, string> | null | undefined,\n): string {\n if (!params) return text;\n return text.replace(/\\{(\\w+)\\}/g, (all, key: string) =>\n Object.prototype.hasOwnProperty.call(params, key)\n ? String(params[key])\n : all,\n );\n}\n\nexport interface LetterText {\n subject: string;\n body: string;\n}\n\n/**\n * What a letter says. Letters of the platform's own templates (a friend's gift, a missed season\n * reward…) come without text unless the publisher rewrote the template — the screen has those texts\n * in both languages (`builtIn`).\n */\nexport function letterText(\n letter: {\n Subject?: string | null;\n Body?: string | null;\n TemplateID?: string | null;\n TemplateParams?: Record<string, string> | null;\n },\n builtIn: (templateID: string) => LetterText | null,\n): LetterText {\n const fallback = letter.TemplateID ? builtIn(letter.TemplateID) : null;\n return {\n subject: fillParams(\n letter.Subject?.trim() || fallback?.subject || \"\",\n letter.TemplateParams,\n ),\n body: fillParams(\n letter.Body?.trim() || fallback?.body || \"\",\n letter.TemplateParams,\n ),\n };\n}\n\n/** Everything \"Claim all\" would take — for the button's count. */\nexport function claimableCount(\n letters: readonly MailboxLetter[],\n pending: readonly MailboxPendingReward[],\n now: number,\n): number {\n return (\n letters.filter((l) => letterState(l, now) === \"claimable\").length +\n pending.length\n );\n}\n\n/** Newer letters first; a page added after \"More\" never duplicates a letter already shown. */\nexport function mergePages(\n shown: readonly MailboxLetter[],\n next: readonly MailboxLetter[],\n): MailboxLetter[] {\n const seen = new Set(shown.map((l) => l.MessageID));\n return [...shown, ...next.filter((l) => !seen.has(l.MessageID))];\n}\n\n// ── Transfers ─────────────────────────────────────────────────────────────\n\n/** Minimal slice of the player's inventory the transfer form reads. */\nexport interface TransferInventory {\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n Items?: Record<string, { StackableAmount?: number } | null> | null;\n}\n\nexport interface TransferOption {\n /** Stable key of the row: \"VirtualCurrency:CO\", \"Item:health_potion\". */\n key: string;\n kind: \"currency\" | \"item\";\n id: string;\n catalogID: string | null;\n owned: number;\n /** The most that can go in one transfer: the title's cap, but never more than the player has. */\n max: number;\n}\n\n/**\n * What the player can transfer: the title's white list (currencies and stackable items only — the\n * server refuses the rest), with how much they own. Rows the player owns nothing of stay, disabled\n * (max 0), so the list does not change shape as balances move.\n */\nexport function transferOptions(\n allowed:\n readonly (TransferableResource | null | undefined)[] | null | undefined,\n inventory: TransferInventory | null | undefined,\n): TransferOption[] {\n const out: TransferOption[] = [];\n for (const r of allowed ?? []) {\n if (!r) continue;\n let option: Omit<TransferOption, \"max\"> | null = null;\n if (r.Type === \"VirtualCurrency\" && r.CurrencyID) {\n option = {\n key: `VirtualCurrency:${r.CurrencyID}`,\n kind: \"currency\",\n id: r.CurrencyID,\n catalogID: null,\n owned: Number(\n inventory?.VirtualCurrencies?.[r.CurrencyID]?.Amount ?? 0,\n ),\n };\n } else if (r.Type === \"Item\" && r.ItemID) {\n option = {\n key: `Item:${r.ItemID}`,\n kind: \"item\",\n id: r.ItemID,\n catalogID: r.CatalogID ?? null,\n owned: Number(inventory?.Items?.[r.ItemID]?.StackableAmount ?? 0),\n };\n }\n if (!option) continue;\n const cap =\n r.MaxAmountPerGift && r.MaxAmountPerGift > 0\n ? r.MaxAmountPerGift\n : Infinity;\n out.push({ ...option, max: Math.max(0, Math.min(cap, option.owned)) });\n }\n return out;\n}\n\n/** The entries of a transfer from the amounts typed: whole, positive, clamped to each row's max. */\nexport function transferEntries(\n options: readonly TransferOption[],\n amounts: Record<string, number>,\n): ResourceEntry[] {\n const entries: ResourceEntry[] = [];\n for (const o of options) {\n const raw = Math.floor(Number(amounts[o.key] ?? 0));\n const amount = Math.min(Number.isFinite(raw) ? raw : 0, o.max);\n if (amount <= 0) continue;\n entries.push(\n o.kind === \"currency\"\n ? { Type: \"VirtualCurrency\", CurrencyID: o.id, Amount: amount }\n : {\n Type: \"Item\",\n ItemID: o.id,\n ...(o.catalogID ? { CatalogID: o.catalogID } : {}),\n Amount: amount,\n },\n );\n }\n return entries;\n}\n\n/** Commission as the server applies it: 0–90 %, anything else clamped. */\nexport function feePercent(value: number | null | undefined): number {\n const p = Number(value ?? 0);\n if (!Number.isFinite(p) || p <= 0) return 0;\n return Math.min(p, 90);\n}\n\n/**\n * The fee of one entry by the platform convention (the server's RateResolver/AmountMath): rounded\n * UP, after snapping double noise to the nearest integer (100 × 0.1 must stay 10, not 11).\n */\nfunction feeOf(amount: number, percent: number): number {\n const v = (amount * percent) / 100;\n if (!(v > 0)) return 0;\n const nearest = Math.round(v);\n if (Math.abs(v - nearest) <= 1e-9 * Math.max(1, Math.abs(v)))\n return Math.min(nearest, amount);\n return Math.min(Math.ceil(v), amount);\n}\n\n/**\n * What the friend receives after the commission — per entry, the friend gets the entry minus its\n * fee; entries that end at zero drop out. The same rule as the server's, so the preview matches\n * the letter.\n */\nexport function afterFee(\n entries: readonly ResourceEntry[],\n percent: number,\n): ResourceEntry[] {\n const p = feePercent(percent);\n return entries\n .map((e) => {\n const amount = Number(e.Amount ?? 0);\n return { ...e, Amount: p === 0 ? amount : amount - feeOf(amount, p) };\n })\n .filter((e) => Number(e.Amount) > 0);\n}\n\n/** The idempotency key of ONE transfer attempt: a retry after a timeout reuses it, never charges twice. */\nexport function newRequestID(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) return c.randomUUID();\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;\n}\n"
61
+ },
62
+ {
63
+ "path": "module.ts",
64
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMailboxScreen, makeMailboxWatcher } from \"./MailboxScreen\";\nimport { mailboxEnabled } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mailbox as a feature of the game: letters with rewards, broadcasts, friends' gifts and\n// transfers, missed rewards of other systems. The always-on watcher keeps the badge — one cheap\n// counter read on start and whenever the player comes back to the tab; every mailbox action brings\n// fresh counters itself (mailbox:countersChanged). Hidden until the title turns the mailbox on.\nexport const mailboxModule: Module = defineModule({\n id: \"mailbox\",\n meta: { name: \"Mailbox\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const enabled = mailboxEnabled(\n configSection<{ Enabled?: boolean | null }>(ctx.client, \"Mailbox\"),\n );\n ctx.features.register({\n id: \"mailbox\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"social\",\n order: 85,\n available: false,\n Screen: makeMailboxScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"mailbox\",\n enabled,\n \"the mailbox is off on the title (Mailbox.Enabled)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeMailboxWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"mailbox\" }),\n describeActions: {},\n });\n },\n});\n"
65
+ },
66
+ {
67
+ "path": "module.meta.json",
68
+ "content": "{\n \"id\": \"mailbox\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.\",\n \"description\": \"A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.\",\n \"provides\": [\n \"mailbox screen\",\n \"letters with rewards and claim all\",\n \"publisher broadcasts in the inbox\",\n \"gifts to friends\",\n \"resource transfers between friends\",\n \"missed rewards of other systems\",\n \"mailbox badge\"\n ],\n \"tags\": [\n \"mailbox\",\n \"mail\",\n \"inbox\",\n \"gifts\",\n \"rewards\",\n \"social\",\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
+ }
70
+ ]
71
+ }
@@ -7,7 +7,6 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
- "inBase": true,
11
10
  "summary": "Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.",
12
11
  "description": "A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.",
13
12
  "provides": [
@@ -37,12 +36,12 @@
37
36
  "version": "0.1.0"
38
37
  },
39
38
  "dependencies": {
40
- "@idosgames/core": "0.19.0",
41
- "@idosgames/module-sdk": "0.6.0",
39
+ "@idosgames/core": "0.19.1",
40
+ "@idosgames/module-sdk": "0.7.0",
42
41
  "@idosgames/react": "0.3.0",
43
42
  "react": "19.2.7"
44
43
  },
45
- "contentHash": "f9cd6340a024f37d6be7101f08dbf9aafbf1a8a52aa05f7ff1f2f43db3bc071d",
44
+ "contentHash": "1996992148c6539aacebc78427829df8c778879e3a89d7ca5d948f081c2bd4e0",
46
45
  "files": [
47
46
  {
48
47
  "path": "i18n.ts",
@@ -66,7 +65,7 @@
66
65
  },
67
66
  {
68
67
  "path": "module.meta.json",
69
- "content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
68
+ "content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
70
69
  }
71
70
  ]
72
71
  }
@@ -30,8 +30,8 @@
30
30
  "version": "0.1.0"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.19.0",
34
- "@idosgames/module-sdk": "0.6.0",
33
+ "@idosgames/core": "0.19.1",
34
+ "@idosgames/module-sdk": "0.7.0",
35
35
  "@idosgames/react": "0.3.0",
36
36
  "react": "19.2.7"
37
37
  },
@@ -0,0 +1,74 @@
1
+ {
2
+ "id": "multiplayer",
3
+ "meta": {
4
+ "name": "Multiplayer",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.",
11
+ "description": "A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.",
12
+ "provides": [
13
+ "multiplayer rooms lobby",
14
+ "rooms of one game",
15
+ "public and private rooms by invite",
16
+ "room settings of the game",
17
+ "room members with ready and host",
18
+ "room chat",
19
+ "host starts the game for the whole room",
20
+ "late joiners into a running game"
21
+ ],
22
+ "tags": [
23
+ "multiplayer",
24
+ "rooms",
25
+ "lobby",
26
+ "realtime",
27
+ "social",
28
+ "ui",
29
+ "screen"
30
+ ],
31
+ "author": {
32
+ "name": "iDos Games",
33
+ "url": "https://idosgames.com"
34
+ },
35
+ "version": "0.1.0"
36
+ },
37
+ "dependencies": {
38
+ "@idosgames/core": "0.19.1",
39
+ "@idosgames/module-sdk": "0.7.0",
40
+ "@idosgames/react": "0.3.0",
41
+ "react": "19.2.7"
42
+ },
43
+ "contentHash": "b318338a4342f30fd665899803aa10ce32764cc1afb3b709b297e5bf329cdeb7",
44
+ "files": [
45
+ {
46
+ "path": "i18n.ts",
47
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Multiplayer\",\n create: \"Create a room\",\n byCode: \"By invite\",\n open: \"Open rooms\",\n roomsOf: \"Rooms:\",\n noRooms: \"No open rooms — create one and invite friends.\",\n noGames: \"No game here plays online yet.\",\n room: \"Room\",\n roomName: \"Room name\",\n invitePlaceholder: \"Invite: room ID or ID:code\",\n invite: \"Invite\",\n copied: \"Copied!\",\n players: \"Players\",\n join: \"Join\",\n public: \"Anyone can join\",\n private: \"Private — by invite\",\n members: \"In the room\",\n you: \"you\",\n player: \"Player\",\n kick: \"Remove\",\n ready: \"Ready\",\n notReady: \"Not ready\",\n waitHost: \"The host starts the game.\",\n play: \"Play\",\n backToGame: \"Back to the game\",\n inProgress: \"The game in this room has already started.\",\n noGame: \"This app does not have the room's game.\",\n startFailed: \"The game could not start the room.\",\n message: \"Message\",\n send: \"Send\",\n noChat: \"Say hi to the room!\",\n leave: \"Leave the room\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Мультиплеер\",\n create: \"Создать комнату\",\n byCode: \"По приглашению\",\n open: \"Открытые комнаты\",\n roomsOf: \"Комнаты:\",\n noRooms: \"Открытых комнат нет — создайте свою и позовите друзей.\",\n noGames: \"Здесь пока нет игр для игры по сети.\",\n room: \"Комната\",\n roomName: \"Название комнаты\",\n invitePlaceholder: \"Приглашение: ID комнаты или ID:код\",\n invite: \"Приглашение\",\n copied: \"Скопировано!\",\n players: \"Игроков\",\n join: \"Войти\",\n public: \"Может войти любой\",\n private: \"Закрытая — по приглашению\",\n members: \"В комнате\",\n you: \"вы\",\n player: \"Игрок\",\n kick: \"Убрать\",\n ready: \"Готов\",\n notReady: \"Не готов\",\n waitHost: \"Игру запускает хост.\",\n play: \"Играть\",\n backToGame: \"Вернуться в игру\",\n inProgress: \"Игра в этой комнате уже началась.\",\n noGame: \"В этом приложении нет игры этой комнаты.\",\n startFailed: \"Игра не смогла принять комнату.\",\n message: \"Сообщение\",\n send: \"Отправить\",\n noChat: \"Поздоровайтесь с комнатой!\",\n leave: \"Выйти из комнаты\",\n});\n"
48
+ },
49
+ {
50
+ "path": "index.ts",
51
+ "content": "// @idosgames/mod-multiplayer — The rooms system for any game: rooms of the app's online games, public or private rooms by invite, members, ready and chat - the host takes the whole room into a game.\n\nexport { multiplayerModule } from \"./module\";\n"
52
+ },
53
+ {
54
+ "path": "model.ts",
55
+ "content": "// Pure helpers of the rooms system — covered by model.test.ts. No React, no network.\n\nimport type { MultiplayerGame } from \"@idosgames/module-sdk\";\n\n/** The title's Multiplayer section, as far as the rooms system cares. */\nexport interface MultiplayerSection {\n SystemState?: { Enabled?: boolean | null } | null;\n MaxPlayersPerRoom?: number | null;\n MemberTimeoutMs?: number | null;\n}\n\nexport interface RoomMember {\n UserID?: string | null;\n Username?: string | null;\n Ready?: boolean | null;\n IsHost?: boolean | null;\n LastSeenMs?: number | null;\n State?: Record<string, string> | null;\n}\n\nexport interface MemberRow {\n userID: string;\n name: string;\n host: boolean;\n ready: boolean;\n online: boolean;\n me: boolean;\n}\n\n/** The room's members: host first, then by name; a member silent past the timeout is offline. */\nexport function memberRows(\n members: Iterable<RoomMember> | null | undefined,\n me: string | null | undefined,\n hostId: string | null | undefined,\n nowMs: number,\n timeoutMs: number,\n): MemberRow[] {\n return [...(members ?? [])]\n .filter((m): m is RoomMember & { UserID: string } => !!m?.UserID)\n .map((m) => ({\n userID: m.UserID,\n name: m.Username || m.UserID,\n host: hostId ? m.UserID === hostId : m.IsHost === true,\n ready: m.Ready === true,\n online: !m.LastSeenMs || nowMs - m.LastSeenMs <= timeoutMs,\n me: m.UserID === me,\n }))\n .sort(\n (a, b) => Number(b.host) - Number(a.host) || a.name.localeCompare(b.name),\n );\n}\n\n// ── The host's member state: the game's settings and \"the game started\" ─────────────────────\n//\n// The server keeps a small string map per member. The host's map carries the room's settings\n// (`set:<id>`) from the moment the room is created, and `start` once \"Play\" is pressed — so a player\n// who joins later reads both from the snapshot, and a guest who missed the DataChannel message still\n// learns about the start from the next member refresh.\n\nexport const START_KEY = \"start\";\nconst SETTING_PREFIX = \"set:\";\n\n/** The room's settings as the host's member state stores them. */\nexport function settingsToState(\n settings: Record<string, string | boolean>,\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [id, value] of Object.entries(settings))\n out[SETTING_PREFIX + id] =\n typeof value === \"boolean\" ? (value ? \"1\" : \"0\") : value;\n return out;\n}\n\n/** The room's settings read back from the host's member state, defaults filled in from the game. */\nexport function settingsFromState(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n state: Record<string, string> | null | undefined,\n): Record<string, string | boolean> {\n const out: Record<string, string | boolean> = {};\n for (const field of game?.settings ?? []) {\n const raw = state?.[SETTING_PREFIX + field.id];\n out[field.id] =\n raw === undefined\n ? field.default\n : field.kind === \"toggle\"\n ? raw === \"1\"\n : field.options?.some((o) => o.value === raw)\n ? raw\n : field.default;\n }\n return out;\n}\n\n/** Default settings of a game (what \"Create a room\" starts with). */\nexport function defaultSettings(\n game: Pick<MultiplayerGame, \"settings\"> | undefined,\n): Record<string, string | boolean> {\n return settingsFromState(game, null);\n}\n\n/** The game mode the host started for the room (null — still in the lobby). */\nexport function startedMode(\n host: RoomMember | null | undefined,\n): string | null {\n return host?.State?.[START_KEY] || null;\n}\n\n// ── The lobby's own DataChannel message ──────────────────────────────────────────────────────\n//\n// \"Play\" reaches guests at once over the links the lobby already opened (the member-state poll of a\n// guest is slow on purpose). The game owns the channels only after it started, and the host sends\n// this before any game message, on the ordered channel — so a game never sees it.\n\nconst START_TAG = \"idos.lobby.start\";\n\nexport function encodeStart(mode: string): Uint8Array {\n return new TextEncoder().encode(JSON.stringify({ t: START_TAG, mode }));\n}\n\n/** The mode a lobby start message carries, or null for anything else. */\nexport function decodeStart(data: Uint8Array): string | null {\n if (data.length > 256 || data[0] !== 0x7b) return null;\n try {\n const msg = JSON.parse(new TextDecoder().decode(data)) as {\n t?: unknown;\n mode?: unknown;\n };\n return msg.t === START_TAG && typeof msg.mode === \"string\"\n ? msg.mode\n : null;\n } catch {\n return null;\n }\n}\n\n// ── Rooms list ───────────────────────────────────────────────────────────────────────────────\n\nexport interface ListedRoom {\n RoomID?: string | null;\n Name?: string | null;\n Mode?: string | null;\n MemberCount?: number | null;\n MaxPlayers?: number | null;\n}\n\n/** Rooms the player can see: only those of a game this app has; of one game when asked. */\nexport function visibleRooms(\n rooms: ListedRoom[],\n games: string[],\n onlyMode: string | null | undefined,\n): ListedRoom[] {\n return rooms.filter(\n (r) =>\n !!r.RoomID &&\n !!r.Mode &&\n games.includes(r.Mode) &&\n (!onlyMode || r.Mode === onlyMode),\n );\n}\n\n/** A join code the way players type it: letters and digits, upper case. */\nexport function cleanCode(value: string): string {\n return value\n .toUpperCase()\n .replace(/[^A-Z0-9]/g, \"\")\n .slice(0, 12);\n}\n\n/** A fresh private-room code: six letters and digits without look-alikes. */\nexport function newCode(random: () => number = Math.random): string {\n const abc = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n let out = \"\";\n for (let i = 0; i < 6; i++) out += abc[Math.floor(random() * abc.length)];\n return out;\n}\n\n/** Whether multiplayer is on for the title. */\nexport function hasTitleData(section: MultiplayerSection | undefined): boolean {\n return !!section && section.SystemState?.Enabled !== false;\n}\n"
56
+ },
57
+ {
58
+ "path": "module.ts",
59
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMultiplayerScreen } from \"./MultiplayerScreen\";\nimport { hasTitleData, type MultiplayerSection } from \"./model\";\nimport { createRooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system of the app: the lobby tab with the rooms, and the owner of the player's room and\n// its connection. Games that play online register in `ctx.multiplayer` and take the room in `start`;\n// they never create or join rooms. The tab shows while multiplayer is on for the title AND the app has\n// a game that plays online. No badge and no startup request.\nexport const multiplayerModule: Module = defineModule({\n id: \"multiplayer\",\n meta: { name: \"Multiplayer\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const rooms = createRooms({\n client: ctx.client,\n games: ctx.multiplayer,\n navigate: (mode) => ctx.navigate(mode),\n });\n ctx.features.register({\n id: \"multiplayer\",\n label: t(\"title\"),\n icon: \"globe\",\n group: \"social\",\n order: 75,\n available: false,\n Screen: makeMultiplayerScreen({\n rooms,\n games: ctx.multiplayer,\n modes: ctx.modes,\n }),\n });\n const on = hasTitleData(\n configSection<MultiplayerSection>(ctx.client, \"Multiplayer\"),\n );\n // Games register in their own setup() — before or after this one: follow the registry.\n const refresh = () =>\n ctx.features.setAvailable(\n \"multiplayer\",\n on && ctx.multiplayer.listGames().length > 0,\n on\n ? \"no game of the app plays online (ctx.multiplayer.registerGame)\"\n : \"multiplayer is off on the title (Multiplayer.SystemState.Enabled)\",\n );\n refresh();\n ctx.multiplayer.subscribe(refresh);\n ctx.exposeToAgent({\n state: () => {\n const s = rooms.get();\n return {\n ui: \"dom\",\n feature: \"multiplayer\",\n games: ctx.multiplayer.listGames().map((g) => g.mode),\n room: s.session\n ? {\n id: s.session.roomId,\n mode: s.session.mode,\n host: s.session.isHost,\n members: s.session.members.size,\n started: s.started,\n }\n : null,\n };\n },\n describeActions: {},\n });\n },\n});\n"
60
+ },
61
+ {
62
+ "path": "MultiplayerScreen.tsx",
63
+ "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { RoomListItem } from \"@idosgames/core\";\nimport type {\n FeatureScreenProps,\n ModeRegistry,\n MultiplayerGame,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n SectionTitle,\n Spinner,\n configSection,\n errorText,\n outlined,\n panel,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n cleanCode,\n defaultSettings,\n memberRows,\n newCode,\n settingsFromState,\n visibleRooms,\n type MultiplayerSection,\n} from \"./model\";\nimport type { Rooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system's screen (a lobby tab): the rooms of the games this app plays online, \"Create a\n// room\" for one of them, joining by code — and, once in a room, its members, \"Ready\", the room chat and\n// the host's \"Play\", which takes the whole room into the game. Opened with `{ mode }` it shows one\n// game's rooms (the lobby's \"With friends\"); with `{ mode, create: true }` straight on \"Create\" (a\n// game's \"Open for friends\").\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n\nconst useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\nexport function makeMultiplayerScreen(deps: ScreenDeps) {\n return function MultiplayerScreen({ args }: FeatureScreenProps): ReactNode {\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const mode = typeof args?.mode === \"string\" ? args.mode : null;\n return state.session ? (\n <Room deps={deps} />\n ) : (\n <Rooms deps={deps} mode={mode} create={args?.create === true} />\n );\n };\n}\n\n/** A game's label: its own, else its route's. */\nfunction gameLabel(\n game: MultiplayerGame | undefined,\n modes: ModeRegistry,\n mode: string | null | undefined,\n): string {\n return (\n game?.label ?? modes.list().find((m) => m.id === mode)?.label ?? mode ?? \"\"\n );\n}\n\n// ── The list of rooms ────────────────────────────────────────────────────────────────────────\n\nfunction Rooms({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n shown.map((r, i) => (\n <Card\n key={r.RoomID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"globe\" size={34} />\n <div style={{ minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {r.Name || t(\"room\")}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {gameLabel(\n deps.games.getGame(r.Mode ?? \"\"),\n deps.modes,\n r.Mode,\n )}{\" \"}\n · {t(\"players\")}: {r.MemberCount ?? 0} / {r.MaxPlayers ?? 8}\n </div>\n </div>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === r.RoomID}\n disabled={(r.MemberCount ?? 0) >= (r.MaxPlayers ?? 8)}\n onClick={() => void join(r.RoomID ?? \"\")}\n >\n {t(\"join\")}\n </Button>\n </Card>\n ))\n )}\n {popup === \"create\" ? (\n <Popup title={t(\"create\")} onClose={() => setPopup(null)} width={420}>\n <CreateForm deps={deps} games={games} preset={mode} />\n </Popup>\n ) : popup === \"code\" ? (\n <Popup title={t(\"byCode\")} onClose={() => setPopup(null)} width={400}>\n <CodeForm onJoin={(id, code) => void join(id, code)} />\n </Popup>\n ) : null}\n </div>\n );\n}\n\nconst input = {\n ...panel,\n padding: \"10px 12px\",\n color: \"#fff\",\n fontSize: 15,\n width: \"100%\",\n boxSizing: \"border-box\" as const,\n};\n\nfunction CreateForm({\n deps,\n games,\n preset,\n}: {\n deps: ScreenDeps;\n games: MultiplayerGame[];\n preset: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [mode, setMode] = useState(\n games.find((g) => g.mode === preset)?.mode ?? games[0]?.mode ?? \"\",\n );\n const game = games.find((g) => g.mode === mode);\n const cap = Math.min(\n Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")\n ?.MaxPlayersPerRoom ?? 8,\n ),\n game?.maxPlayers ?? 8,\n );\n const sizes = [2, 4, 8].filter((n) => n <= cap);\n const [name, setName] = useState(\"\");\n const [max, setMax] = useState(sizes[sizes.length - 1] ?? 2);\n const [priv, setPriv] = useState(false);\n const [settings, setSettings] = useState(() => defaultSettings(game));\n const [busy, setBusy] = useState(false);\n useEffect(() => setSettings(defaultSettings(game)), [mode]);\n\n if (!game) return <EmptyState glyph=\"globe\" text={t(\"noGames\")} />;\n\n const create = async () => {\n setBusy(true);\n const error = await deps.rooms.create({\n name: name.trim() || t(\"room\"),\n mode,\n maxPlayers: max,\n private: priv,\n joinCode: newCode(),\n settings,\n });\n setBusy(false);\n if (error) return toast(errorText(error), \"error\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n {games.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {games.map((g) => (\n <Button\n key={g.mode}\n size=\"sm\"\n tone={g.mode === mode ? \"gold\" : \"grey\"}\n onClick={() => setMode(g.mode)}\n >\n {gameLabel(g, deps.modes, g.mode)}\n </Button>\n ))}\n </div>\n ) : null}\n <input\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={input}\n />\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n {t(\"players\")}:\n {sizes.map((n) => (\n <Button\n key={n}\n size=\"sm\"\n tone={max === n ? \"gold\" : \"grey\"}\n onClick={() => setMax(n)}\n >\n {n}\n </Button>\n ))}\n </div>\n <Button\n tone={priv ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setPriv(!priv)}\n >\n <Icon glyph=\"lock\" size={16} /> {priv ? t(\"private\") : t(\"public\")}\n </Button>\n {(game.settings ?? []).map((field) =>\n field.kind === \"toggle\" ? (\n <Button\n key={field.id}\n size=\"sm\"\n tone={settings[field.id] === true ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({\n ...settings,\n [field.id]: settings[field.id] !== true,\n })\n }\n >\n {settings[field.id] === true ? \"✓ \" : \"\"}\n {field.label}\n </Button>\n ) : (\n <div key={field.id} style={{ display: \"grid\", gap: 6 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {field.label}\n </span>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {(field.options ?? []).map((o) => (\n <Button\n key={o.value}\n size=\"sm\"\n tone={settings[field.id] === o.value ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({ ...settings, [field.id]: o.value })\n }\n >\n {o.label}\n </Button>\n ))}\n </div>\n </div>\n ),\n )}\n <Button tone=\"green\" busy={busy} onClick={() => void create()}>\n {t(\"create\")}\n </Button>\n </div>\n );\n}\n\nfunction CodeForm({\n onJoin,\n}: {\n onJoin: (roomID: string, code: string) => void;\n}): ReactNode {\n const close = usePopupClose();\n const [invite, setInvite] = useState(\"\");\n // An invite is \"<room id>:<code>\" (what the room screen shows for copying); a bare id works too.\n const [roomId, code = \"\"] = invite.trim().split(\":\");\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={invite}\n placeholder={t(\"invitePlaceholder\")}\n onChange={(e) => setInvite(e.target.value)}\n style={input}\n />\n <Button\n tone=\"green\"\n disabled={!roomId}\n onClick={() => {\n close();\n onJoin(roomId ?? \"\", cleanCode(code));\n }}\n >\n {t(\"join\")}\n </Button>\n </div>\n );\n}\n\n// ── In a room ────────────────────────────────────────────────────────────────────────────────\n\nfunction Room({ deps }: { deps: ScreenDeps }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const now = useNow();\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const session = state.session!;\n const game = deps.games.getGame(session.mode ?? \"\");\n const timeout = Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")?.MemberTimeoutMs ??\n 15_000,\n );\n const rows = memberRows(\n session.members.values(),\n session.selfId,\n session.hostId,\n now,\n timeout,\n );\n const me = rows.find((r) => r.me);\n const nameOf = (id: string) =>\n rows.find((r) => r.userID === id)?.name ?? t(\"player\");\n const settings = settingsFromState(\n game,\n session.members.get(session.hostId)?.State,\n );\n const invite = `${session.roomId}${state.joinCode ? `:${state.joinCode}` : \"\"}`;\n\n const act = async (run: () => Promise<string | null>) => {\n setBusy(true);\n const error = await run();\n setBusy(false);\n if (error) toast(errorText(error), \"error\");\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {gameLabel(game, deps.modes, session.mode)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"players\")}: {rows.length}\n {(game?.settings ?? []).map((f) => {\n const value = settings[f.id];\n const shown =\n f.kind === \"toggle\"\n ? value === true\n ? f.label\n : null\n : `${f.label}: ${f.options?.find((o) => o.value === value)?.label ?? value}`;\n return shown ? ` · ${shown}` : null;\n })}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() =>\n void navigator.clipboard\n ?.writeText(invite)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n border: \"none\",\n background: \"none\",\n padding: 0,\n textAlign: \"left\",\n cursor: \"pointer\",\n ...outlined,\n fontSize: 12,\n color: v.gold,\n }}\n >\n <Icon glyph=\"copy\" size={14} /> {t(\"invite\")}: {invite}\n </button>\n </div>\n\n {state.started ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n onClick={() => deps.rooms.resume()}\n >\n {t(\"backToGame\")}\n </Button>\n ) : null}\n {state.error ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(state.error === \"noGame\" ? \"noGame\" : \"startFailed\")}\n </div>\n ) : null}\n\n <SectionTitle>{t(\"members\")}</SectionTitle>\n {rows.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n opacity: m.online ? 1 : 0.5,\n }}\n >\n <Icon glyph={m.host ? \"crown\" : \"user\"} size={24} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\n </span>\n {m.ready ? <Icon glyph=\"check\" size={20} /> : null}\n {session.isHost && !m.me && !state.started ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(() => deps.rooms.kick(m.userID))}\n >\n {t(\"kick\")}\n </Button>\n ) : null}\n </Card>\n ))}\n\n {state.started ? null : session.isHost ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n disabled={!game}\n onClick={() => void act(() => deps.rooms.start())}\n data-tutorial-anchor=\"multiplayer:play\"\n >\n {t(\"play\")} · {gameLabel(game, deps.modes, session.mode)}\n </Button>\n ) : (\n <>\n <Button\n tone={me?.ready ? \"grey\" : \"green\"}\n busy={busy}\n onClick={() => void act(() => deps.rooms.setReady(!me?.ready))}\n >\n {me?.ready ? t(\"notReady\") : t(\"ready\")}\n </Button>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"waitHost\")}\n </div>\n </>\n )}\n\n {session.chatMode === \"ServerRelay\" ? (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div\n style={{\n display: \"grid\",\n gap: 4,\n maxHeight: 180,\n overflowY: \"auto\",\n }}\n >\n {state.chat.length === 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noChat\")}\n </div>\n ) : (\n state.chat.map((c, i) => (\n <div key={i} style={{ fontSize: 13, color: \"#fff\" }}>\n <b>{nameOf(c.from)}:</b> {c.text}\n </div>\n ))\n )}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={200}\n placeholder={t(\"message\")}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key !== \"Enter\") return;\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n style={{ ...input, flex: 1 }}\n />\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => {\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n >\n {t(\"send\")}\n </Button>\n </div>\n </div>\n ) : null}\n\n <Button tone=\"grey\" size=\"sm\" onClick={() => void deps.rooms.leave()}>\n {t(\"leave\")}\n </Button>\n </div>\n );\n}\n"
64
+ },
65
+ {
66
+ "path": "rooms.ts",
67
+ "content": "import type { IDosGamesClient } from \"@idosgames/core\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport type {\n MultiplayerRegistry,\n MultiplayerRoom,\n} from \"@idosgames/module-sdk\";\nimport {\n START_KEY,\n decodeStart,\n encodeStart,\n settingsFromState,\n settingsToState,\n startedMode,\n} from \"./model\";\n\n// The player's room, owned by the module rather than by its screen: it has to outlive the screen —\n// the room goes into a game and comes back to the lobby, and the session (with its WebRTC links) is\n// the same all along. The screen only draws this state and calls these actions.\n//\n// Start protocol: the host writes `start = <mode>` into its member state (for late joiners and for a\n// guest who missed the message) and sends a start message over the links the lobby already opened\n// (instant, before any game message on the ordered channel). Every device then switches to the game's\n// mode and hands the room to the game's `start`. From that moment the game owns the channels.\n\nexport interface ChatLine {\n from: string;\n text: string;\n at: number;\n}\n\nexport interface RoomsState {\n session: RealtimeSession | null;\n /** The private room's code — known to its creator (to share it) and to whoever typed it. */\n joinCode: string | null;\n /** The game the room went into (null — still in the lobby). */\n started: string | null;\n chat: ChatLine[];\n /** A problem to show (a game the app does not have, a start that failed). */\n error: string | null;\n}\n\nexport interface RoomsDeps {\n client: IDosGamesClient;\n games: MultiplayerRegistry;\n navigate: (modeId: string) => void;\n}\n\n/** Test seam: how sessions are opened. */\nexport interface SessionFactory {\n create: typeof RealtimeSession.create;\n join: typeof RealtimeSession.join;\n}\n\nexport interface CreateOptions {\n name: string;\n mode: string;\n maxPlayers: number;\n private: boolean;\n joinCode: string;\n settings: Record<string, string | boolean>;\n}\n\nexport function createRooms(\n deps: RoomsDeps,\n factory: SessionFactory = {\n create: (...a) => RealtimeSession.create(...a),\n join: (...a) => RealtimeSession.join(...a),\n },\n) {\n let state: RoomsState = {\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n };\n const listeners = new Set<() => void>();\n let offs: Array<() => void> = [];\n const set = (patch: Partial<RoomsState>) => {\n state = { ...state, ...patch };\n for (const l of listeners) l();\n };\n\n const reset = () => {\n for (const off of offs.splice(0)) off();\n set({\n session: null,\n joinCode: null,\n started: null,\n chat: [],\n error: null,\n });\n };\n\n /** Hand the room to the game — once per room. */\n const enter = (mode: string, lateJoin: boolean) => {\n const session = state.session;\n if (!session || state.started) return;\n const game = deps.games.getGame(mode);\n if (!game) {\n set({ error: \"noGame\" });\n return;\n }\n const host = session.members.get(session.hostId);\n const room: MultiplayerRoom = {\n session,\n mode,\n isHost: session.isHost,\n settings: settingsFromState(game, host?.State),\n lateJoin,\n leave: () => api.leave(),\n };\n set({ started: mode, error: null });\n deps.navigate(mode);\n // After the switch: the game's scene becomes active on the next render.\n setTimeout(() => {\n void Promise.resolve(game.start(room)).catch(() =>\n set({ error: \"startFailed\" }),\n );\n }, 0);\n };\n\n const attach = (session: RealtimeSession, joinCode: string | null) => {\n for (const off of offs.splice(0)) off();\n set({ session, joinCode, started: null, chat: [], error: null });\n offs = [\n session.on(\"message\", ({ from, data }) => {\n if (state.started || from !== session.hostId) return;\n const mode = decodeStart(data);\n if (mode) enter(mode, false);\n }),\n session.on(\"membersUpdated\", () => {\n const mode = startedMode(session.members.get(session.hostId));\n if (mode && !state.started) enter(mode, false);\n else set({});\n }),\n session.on(\"hostChanged\", () => set({})),\n session.on(\"chat\", ({ from, text }) =>\n set({\n chat: [...state.chat, { from, text, at: Date.now() }].slice(-100),\n }),\n ),\n session.on(\"roomClosed\", () => reset()),\n session.on(\"removed\", () => reset()),\n ];\n };\n\n const api = {\n get: () => state,\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n\n /** Create a room for a game — this player hosts it. */\n async create(o: CreateOptions): Promise<string | null> {\n const game = deps.games.getGame(o.mode);\n if (!game) return \"noGame\";\n const res = await factory.create(deps.client, o.name, {\n topology: game.topology ?? \"Mesh\",\n chatMode: game.chatMode ?? \"ServerRelay\",\n maxPlayers: o.maxPlayers,\n visibility: o.private ? \"Private\" : \"Public\",\n joinCode: o.private ? o.joinCode : undefined,\n mode: o.mode,\n });\n if (!res.ok) return res.error;\n attach(res.data, o.private ? o.joinCode : null);\n // The settings live in the host's member state: whoever joins reads them from the snapshot.\n const saved = await deps.client.multiplayer.setMemberState(\n res.data.roomId,\n undefined,\n settingsToState(o.settings),\n );\n if (!saved.ok) return saved.error;\n await res.data.refreshMembers();\n return null;\n },\n\n /** Join a room; into its game right away when the game is running and lets players in. */\n async join(roomId: string, joinCode?: string): Promise<string | null> {\n const res = await factory.join(\n deps.client,\n roomId,\n joinCode || undefined,\n );\n if (!res.ok) return res.error;\n const session = res.data;\n attach(session, joinCode || null);\n const running = startedMode(session.members.get(session.hostId));\n if (running) {\n if (deps.games.getGame(running)?.joinInProgress) enter(running, true);\n else {\n await api.leave();\n return \"inProgress\";\n }\n }\n return null;\n },\n\n async setReady(ready: boolean): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await deps.client.multiplayer.setMemberState(s.roomId, ready);\n return res.ok ? null : res.error;\n },\n\n async sendChat(text: string): Promise<string | null> {\n const s = state.session;\n if (!s || !text.trim()) return null;\n const res = await deps.client.multiplayer.sendChat(s.roomId, text.trim());\n return res.ok ? null : res.error;\n },\n\n /** The host takes the whole room into its game. */\n async start(): Promise<string | null> {\n const s = state.session;\n const mode = s?.mode;\n if (!s || !mode || !s.isHost) return null;\n const host = s.members.get(s.selfId);\n const res = await deps.client.multiplayer.setMemberState(s.roomId, true, {\n ...(host?.State ?? {}),\n [START_KEY]: mode,\n });\n if (!res.ok) return res.error;\n s.broadcast(\"rel\", encodeStart(mode));\n enter(mode, false);\n return null;\n },\n\n /** Back to the running game from the lobby (the room is still on). */\n resume() {\n if (state.started) deps.navigate(state.started);\n },\n\n async kick(userId: string): Promise<string | null> {\n const s = state.session;\n if (!s) return null;\n const res = await s.kick(userId);\n return res.ok ? null : res.error;\n },\n\n async leave(): Promise<void> {\n const s = state.session;\n reset();\n await s?.leave();\n },\n };\n return api;\n}\n\nexport type Rooms = ReturnType<typeof createRooms>;\n"
68
+ },
69
+ {
70
+ "path": "module.meta.json",
71
+ "content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
72
+ }
73
+ ]
74
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "id": "premium",
3
+ "meta": {
4
+ "name": "VIP",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.",
11
+ "description": "A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.",
12
+ "provides": [
13
+ "VIP passes screen",
14
+ "free VIP trial",
15
+ "buy or extend VIP with in-game currency",
16
+ "VIP timer",
17
+ "badge for a free trial"
18
+ ],
19
+ "tags": [
20
+ "vip",
21
+ "premium",
22
+ "subscription",
23
+ "monetization",
24
+ "ui",
25
+ "screen",
26
+ "economy"
27
+ ],
28
+ "author": {
29
+ "name": "iDos Games",
30
+ "url": "https://idosgames.com"
31
+ },
32
+ "version": "0.1.0"
33
+ },
34
+ "dependencies": {
35
+ "@idosgames/core": "0.19.1",
36
+ "@idosgames/module-sdk": "0.7.0",
37
+ "@idosgames/react": "0.3.0",
38
+ "react": "19.2.7"
39
+ },
40
+ "contentHash": "ec99b4243633026ae61d43679fca7b37731033cd262bae6f5cc17479d1e67e7a",
41
+ "files": [
42
+ {
43
+ "path": "i18n.ts",
44
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"VIP\",\n active: \"VIP is active!\",\n activeFor: \"Active for\",\n days: \"days\",\n trial: \"Try free\",\n buy: \"Buy\",\n extend: \"Extend\",\n noPasses: \"No VIP in this game yet. Add it in LiveOps → Premium.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"VIP\",\n active: \"VIP активен!\",\n activeFor: \"Действует ещё\",\n days: \"дн.\",\n trial: \"Попробовать бесплатно\",\n buy: \"Купить\",\n extend: \"Продлить\",\n noPasses: \"VIP в этой игре пока нет. Добавьте его в LiveOps → Premium.\",\n});\n"
45
+ },
46
+ {
47
+ "path": "index.ts",
48
+ "content": "// @idosgames/mod-premium — VIP for any game: the title's premium passes with their benefits, a free trial and buying a pass with in-game currency.\n\nexport { premiumModule } from \"./module\";\n"
49
+ },
50
+ {
51
+ "path": "model.ts",
52
+ "content": "// Pure helpers of the VIP screen — covered by model.test.ts. The server is the authority on what a\n// player holds; these only decide what the screen draws.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface PremiumDefinitionView {\n PremiumID?: string | null;\n DisplayName?: string | null;\n Tier?: number | null;\n DurationDays?: number | null;\n TrialDurationDays?: number | null;\n Benefits?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n}\n\n/** The title's Premium section, as far as the VIP screen cares. */\nexport interface PremiumSection {\n Definitions?: Record<string, PremiumDefinitionView | null> | null;\n}\n\n/** The player's premium state (UserState.Premium). */\nexport interface PremiumStateView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null | undefined\n > | null;\n ActivatedTrialIDs?: string[] | null;\n}\n\nexport interface PassRow {\n premiumID: string;\n name: string;\n tier: number;\n durationDays: number;\n benefits: string[];\n /** Ways to buy with currencies/items (a pass paid in a store is the platform's checkout). */\n options: Array<{ id: string; price: ResourceLine[] }>;\n /** Until when the player holds it (ISO), null — not held. */\n activeUntil: string | null;\n /** A free trial the player has not used yet (and does not hold the pass now). */\n trialDays: number;\n}\n\n/** Every pass of the title, lowest tier first, with the player's state. */\nexport function passRows(\n section: PremiumSection | null | undefined,\n state: PremiumStateView | null | undefined,\n nowMs: number,\n): PassRow[] {\n const rows: PassRow[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const premiumID = def.PremiumID || id;\n const until = state?.Subscriptions?.[premiumID]?.ExpirationDate ?? null;\n const active = !!until && new Date(until).getTime() > nowMs ? until : null;\n const trialUsed = (state?.ActivatedTrialIDs ?? []).includes(premiumID);\n rows.push({\n premiumID,\n name: def.DisplayName || premiumID,\n tier: Number(def.Tier ?? 0),\n durationDays: Number(def.DurationDays ?? 0),\n benefits: Object.values(def.Benefits ?? {}).filter(Boolean),\n options: Object.entries(def.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o?.OptionID || oid,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0),\n activeUntil: active,\n trialDays:\n !active && !trialUsed\n ? Math.max(0, Number(def.TrialDurationDays ?? 0))\n : 0,\n });\n }\n return rows.sort(\n (a, b) => a.tier - b.tier || a.premiumID.localeCompare(b.premiumID),\n );\n}\n\n/** Badge: free trials waiting to be taken. */\nexport function premiumBadge(rows: PassRow[]): number {\n return rows.filter((r) => r.trialDays > 0).length;\n}\n\n/** Whether the title has a pass — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: PremiumSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(Boolean);\n}\n"
53
+ },
54
+ {
55
+ "path": "module.ts",
56
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { PremiumScreen, makePremiumWatcher } from \"./PremiumScreen\";\nimport { hasTitleData, type PremiumSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP as a feature of the game. Events and season send the player here for their VIP prizes\n// (features.open(\"premium\", { premiumID })). Badge: a free trial waiting.\nexport const premiumModule: Module = defineModule({\n id: \"premium\",\n meta: { name: \"VIP\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"premium\",\n label: t(\"title\"),\n icon: \"crown\",\n group: \"economy\",\n order: 12,\n available: false,\n Screen: PremiumScreen,\n });\n const enabled = hasTitleData(\n configSection<PremiumSection>(ctx.client, \"Premium\"),\n );\n ctx.features.setAvailable(\n \"premium\",\n enabled,\n \"no premium pass on the title (Premium)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makePremiumWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"premium\" }),\n describeActions: {},\n });\n },\n});\n"
57
+ },
58
+ {
59
+ "path": "PremiumScreen.tsx",
60
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\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 ResourceList,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n panel,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumSection,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Fresh state on open: a pass may have run out since the last read.\n useEffect(() => {\n void client.premium.getUserState();\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </div>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #8a5a12 0%, #4a2d06 100%)\",\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function makePremiumWatcher(features: FeatureRegistry) {\n return function PremiumBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n }, [client, state?.Premium]);\n return null;\n };\n}\n"
61
+ },
62
+ {
63
+ "path": "module.meta.json",
64
+ "content": "{\n \"id\": \"premium\",\n \"type\": \"feature\",\n \"summary\": \"VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.\",\n \"description\": \"A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.\",\n \"provides\": [\n \"VIP passes screen\",\n \"free VIP trial\",\n \"buy or extend VIP with in-game currency\",\n \"VIP timer\",\n \"badge for a free trial\"\n ],\n \"tags\": [\n \"vip\",\n \"premium\",\n \"subscription\",\n \"monetization\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
65
+ }
66
+ ]
67
+ }