@idosgames/mcp 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +13 -13
- package/registry/index.json +474 -147
- package/registry/modules/ai.json +65 -0
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +66 -0
- package/registry/modules/character.json +7 -8
- package/registry/modules/chat.json +7 -8
- package/registry/modules/collections.json +69 -0
- package/registry/modules/coop-events.json +67 -0
- package/registry/modules/craft.json +7 -8
- package/registry/modules/deals.json +7 -8
- package/registry/modules/events.json +10 -15
- package/registry/modules/friends.json +8 -8
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory.json +16 -19
- package/registry/modules/invite.json +9 -9
- package/registry/modules/leaderboard.json +7 -8
- package/registry/modules/lootboxes.json +67 -0
- package/registry/modules/mailbox.json +71 -0
- package/registry/modules/marketplace.json +7 -8
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +74 -0
- package/registry/modules/premium.json +67 -0
- package/registry/modules/profile.json +11 -12
- package/registry/modules/pvp.json +5 -6
- package/registry/modules/quests.json +69 -0
- package/registry/modules/rewards.json +17 -23
- package/registry/modules/season.json +10 -15
- package/registry/modules/store.json +7 -8
- package/registry/modules/tutorials.json +7 -8
- package/registry/modules/voxelcraft.json +16 -12
- package/registry/modules/workshop.json +7 -8
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/mailbox-system.json +6 -0
- package/registry/skills/multiplayer-realtime.json +2 -2
- 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.8.0",
|
|
41
|
+
"@idosgames/react": "0.4.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": [
|
|
@@ -34,15 +33,15 @@
|
|
|
34
33
|
"name": "iDos Games",
|
|
35
34
|
"url": "https://idosgames.com"
|
|
36
35
|
},
|
|
37
|
-
"version": "0.1.
|
|
36
|
+
"version": "0.1.1"
|
|
38
37
|
},
|
|
39
38
|
"dependencies": {
|
|
40
|
-
"@idosgames/core": "0.19.
|
|
41
|
-
"@idosgames/module-sdk": "0.
|
|
42
|
-
"@idosgames/react": "0.
|
|
39
|
+
"@idosgames/core": "0.19.1",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
41
|
+
"@idosgames/react": "0.4.0",
|
|
43
42
|
"react": "19.2.7"
|
|
44
43
|
},
|
|
45
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "9c8b1bfa6b479da0a046ff6b5ef61d1f01bec41efafca6941e0075343a780610",
|
|
46
45
|
"files": [
|
|
47
46
|
{
|
|
48
47
|
"path": "i18n.ts",
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
},
|
|
55
54
|
{
|
|
56
55
|
"path": "MarketplaceScreen.tsx",
|
|
57
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\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 EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace.getDefinitions().then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.user.getUserInventory();\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyLeadingBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(0,0,0,.3)\",\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
56
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\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 EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace.getDefinitions().then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.user.getUserInventory();\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: v.wellSoft,\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
58
57
|
},
|
|
59
58
|
{
|
|
60
59
|
"path": "model.ts",
|
|
@@ -66,7 +65,7 @@
|
|
|
66
65
|
},
|
|
67
66
|
{
|
|
68
67
|
"path": "module.meta.json",
|
|
69
|
-
"content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\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.1\"\n}\n"
|
|
70
69
|
}
|
|
71
70
|
]
|
|
72
71
|
}
|
|
@@ -27,15 +27,15 @@
|
|
|
27
27
|
"name": "iDos Games",
|
|
28
28
|
"url": "https://idosgames.com"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.1.
|
|
30
|
+
"version": "0.1.1"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.19.
|
|
34
|
-
"@idosgames/module-sdk": "0.
|
|
35
|
-
"@idosgames/react": "0.
|
|
33
|
+
"@idosgames/core": "0.19.1",
|
|
34
|
+
"@idosgames/module-sdk": "0.8.0",
|
|
35
|
+
"@idosgames/react": "0.4.0",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
|
-
"contentHash": "
|
|
38
|
+
"contentHash": "eb39139d93bfb84e5a20d2485b081a264234452b5d90978fdf15b940cf3b7ac0",
|
|
39
39
|
"files": [
|
|
40
40
|
{
|
|
41
41
|
"path": "i18n.ts",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"path": "MiniGamePanel.tsx",
|
|
54
|
-
"content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n <div style={{ maxWidth: 760, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background:
|
|
54
|
+
"content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n <div style={{ maxWidth: 760, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "model.ts",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "module.meta.json",
|
|
66
|
-
"content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.
|
|
66
|
+
"content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
69
|
}
|