@idosgames/mcp 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +2 -2
  3. package/registry/index.json +124 -124
  4. package/registry/modules/ai.json +5 -5
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/boosts.json +7 -7
  7. package/registry/modules/character.json +6 -6
  8. package/registry/modules/chat.json +3 -3
  9. package/registry/modules/collections.json +5 -5
  10. package/registry/modules/coop-events.json +5 -5
  11. package/registry/modules/craft.json +5 -5
  12. package/registry/modules/deals.json +5 -5
  13. package/registry/modules/events.json +5 -5
  14. package/registry/modules/friends.json +5 -5
  15. package/registry/modules/idle-rpg.json +8 -8
  16. package/registry/modules/inventory.json +5 -5
  17. package/registry/modules/invite.json +5 -5
  18. package/registry/modules/leaderboard.json +5 -5
  19. package/registry/modules/lootboxes.json +6 -6
  20. package/registry/modules/mailbox.json +5 -5
  21. package/registry/modules/marketplace.json +6 -6
  22. package/registry/modules/mini-game.json +3 -3
  23. package/registry/modules/multiplayer.json +7 -7
  24. package/registry/modules/premium.json +5 -5
  25. package/registry/modules/profile.json +7 -7
  26. package/registry/modules/pvp.json +7 -7
  27. package/registry/modules/quests.json +5 -5
  28. package/registry/modules/rewards.json +5 -5
  29. package/registry/modules/season.json +5 -5
  30. package/registry/modules/store.json +5 -5
  31. package/registry/modules/tutorials.json +3 -3
  32. package/registry/modules/voxelcraft.json +6 -6
  33. package/registry/modules/workshop.json +3 -3
  34. package/registry/skills/authentication.json +1 -1
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/community-marketing-system.json +1 -1
  37. package/registry/skills/idosgames-getting-started.json +1 -1
  38. package/registry/skills/idosgames-ui-kit.json +1 -1
  39. package/registry/skills/mailbox-system.json +1 -1
  40. package/registry/skills/match-system.json +2 -2
  41. package/registry/skills/multiplayer-realtime.json +2 -2
@@ -35,16 +35,16 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.19.1",
39
- "@idosgames/module-sdk": "0.8.0",
40
- "@idosgames/react": "0.4.0",
38
+ "@idosgames/core": "0.21.0",
39
+ "@idosgames/module-sdk": "0.8.2",
40
+ "@idosgames/react": "0.4.3",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "f5c0187a70b1b22166f4ff70aa228339d4ef0a35acd2837bc3a1129e52576910",
43
+ "contentHash": "baae3cd9af04f1f2286605348c8eed3570add2eec30357f0b0188d32a63cbe3d",
44
44
  "files": [
45
45
  {
46
46
  "path": "FriendsScreen.tsx",
47
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\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 {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
47
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // Subscribed directly, not keyed on the state object: the cache patches the social counters in\n // place, and an effect that does not re-run leaves the badge lit after a request is answered.\n let unread = 0;\n const update = () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n client.data.user.state?.Social as\n { IncomingCount?: number } | undefined,\n unread,\n ),\n );\n const setUnread = (n: number) => {\n unread = n;\n update();\n };\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n update();\n load();\n const id = setInterval(load, 5 * 60_000);\n const offs = [\n client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n ),\n client.on(\"user:anyUpdated\", update),\n ];\n return () => {\n clearInterval(id);\n for (const off of offs) off();\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\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 {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
48
48
  },
49
49
  {
50
50
  "path": "i18n.ts",
@@ -49,17 +49,17 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@idosgames/core": "0.19.1",
53
- "@idosgames/module-sdk": "0.8.0",
54
- "@idosgames/react": "0.4.0",
55
- "@idosgames/wallet": "0.6.0",
52
+ "@idosgames/core": "0.21.0",
53
+ "@idosgames/module-sdk": "0.8.2",
54
+ "@idosgames/react": "0.4.3",
55
+ "@idosgames/wallet": "0.6.2",
56
56
  "@tanstack/react-query": "5.101.2",
57
57
  "phaser": "4.2.1",
58
58
  "react": "19.2.7",
59
59
  "viem": "2.55.2",
60
60
  "wagmi": "3.7.2"
61
61
  },
62
- "contentHash": "41b86a0971beeab985e44ed77e481ce38c29df0c2fe01e34fd50159d64646e97",
62
+ "contentHash": "dc9f4f6186cdd2d62de226116fa88c1d4d14d16469b92514b90ab28bdcf4c6c4",
63
63
  "files": [
64
64
  {
65
65
  "path": "chrome.ts",
@@ -71,7 +71,7 @@
71
71
  },
72
72
  {
73
73
  "path": "components/CharacterRoster.tsx",
74
- "content": "import {\n useEffect,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n type Availability,\n} from \"../data/characterConfig\";\n\nexport interface RosterEntry {\n id: string;\n name: string;\n rarity?: string;\n className?: string;\n availability: Availability;\n level?: number;\n power?: number;\n}\n\n/** Loads character definitions + the player's characters, merges them into a roster, and keeps\n * it live across cache updates. */\nexport function useRoster(): { entries: RosterEntry[]; loading: boolean } {\n const client = useIDosGamesClient();\n const [loading, setLoading] = useState(true);\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.character.getCharacterDefinitions();\n await client.character.getUserCharacters();\n if (active) setLoading(false);\n })();\n const off = client.on(\"user:anyUpdated\", () => {\n force();\n });\n return () => {\n active = false;\n off();\n };\n }, [client]);\n\n const config = readCharacterConfig(client);\n const defs = config.Definitions ?? {};\n const owned = client.data.user.state?.Character?.Characters ?? {};\n const ids = new Set<string>([...Object.keys(defs), ...Object.keys(owned)]);\n\n const entries: RosterEntry[] = [...ids]\n .map((id) => {\n const def = defs[id];\n const character: CharacterModel | undefined = owned[id];\n const entry: RosterEntry = {\n id,\n name: def?.Identity?.DisplayName ?? id,\n rarity: def?.Classification?.RarityID,\n className: def?.Classification?.ClassID,\n availability: resolveAvailability(def, character !== undefined),\n level: character?.Level ?? undefined,\n power: character?.Power ?? undefined,\n };\n return { entry, sortOrder: def?.Identity?.SortOrder ?? 999 };\n })\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((x) => x.entry);\n\n return { entries, loading };\n}\n\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n};\n\nconst statusStyle: Record<Availability, CSSProperties> = {\n owned: {\n color: \"#ffffff\",\n background: \"#052e7e\",\n border: \"1px solid #1e59c8\",\n },\n available: {\n color: \"#cfe0ff\",\n background: \"#063073\",\n border: \"1px solid #3b78e0\",\n },\n locked: {\n color: \"#a9c6ff\",\n background: \"#04276b\",\n border: \"1px solid #1e59c8\",\n },\n};\n\nfunction card(availability: Availability, selected: boolean): CSSProperties {\n return {\n textAlign: \"left\",\n borderRadius: 12,\n padding: 12,\n cursor: \"pointer\",\n ...statusStyle[availability],\n ...(selected ? { border: \"2px solid #ffd479\" } : {}),\n };\n}\n\nexport function rosterStatusLine(entry: RosterEntry): string {\n switch (entry.availability) {\n case \"owned\":\n return `Lv ${entry.level ?? 0} · Power ${entry.power ?? 0}`;\n case \"available\":\n return \"Available\";\n case \"locked\":\n return \"Locked\";\n }\n}\n\nexport function CharacterRoster({\n selectedID,\n onSelect,\n}: {\n selectedID: string | null;\n onSelect: (entry: RosterEntry) => void;\n}): ReactNode {\n const { entries, loading } = useRoster();\n if (loading) return <div style={{ opacity: 0.6 }}>Loading characters…</div>;\n if (entries.length === 0)\n return <div style={{ opacity: 0.6 }}>No characters configured.</div>;\n\n return (\n <div style={grid}>\n {entries.map((entry) => (\n <button\n key={entry.id}\n type=\"button\"\n style={card(entry.availability, entry.id === selectedID)}\n onClick={() => {\n onSelect(entry);\n }}\n >\n <div style={{ fontWeight: 700, marginBottom: 4 }}>{entry.name}</div>\n <div style={{ fontSize: 11, opacity: 0.7 }}>\n {[entry.rarity, entry.className].filter(Boolean).join(\" · \")}\n </div>\n <div style={{ fontSize: 13, marginTop: 6 }}>\n {rosterStatusLine(entry)}\n </div>\n </button>\n ))}\n </div>\n );\n}\n"
74
+ "content": "import {\n useEffect,\n useReducer,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { CharacterModel } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n readCharacterConfig,\n resolveAvailability,\n type Availability,\n} from \"../data/characterConfig\";\n\nexport interface RosterEntry {\n id: string;\n name: string;\n rarity?: string;\n className?: string;\n availability: Availability;\n level?: number;\n power?: number;\n}\n\n/** Merges character definitions and the player's characters into a roster, and keeps it live\n * across cache updates. Both come with the login (title config + state); the central cache asks\n * the server only for what is missing (definitions the config lacks, a hero given by default). */\nexport function useRoster(): { entries: RosterEntry[]; loading: boolean } {\n const client = useIDosGamesClient();\n const [loading, setLoading] = useState(true);\n const [, force] = useReducer((n: number) => n + 1, 0);\n\n useEffect(() => {\n let active = true;\n void (async () => {\n await client.character.getCharacterDefinitions();\n await client.cache.ensureState([\"Character\"]);\n if (active) setLoading(false);\n })();\n const off = client.on(\"user:anyUpdated\", () => {\n force();\n });\n return () => {\n active = false;\n off();\n };\n }, [client]);\n\n const config = readCharacterConfig(client);\n const defs = config.Definitions ?? {};\n const owned = client.data.user.state?.Character?.Characters ?? {};\n const ids = new Set<string>([...Object.keys(defs), ...Object.keys(owned)]);\n\n const entries: RosterEntry[] = [...ids]\n .map((id) => {\n const def = defs[id];\n const character: CharacterModel | undefined = owned[id];\n const entry: RosterEntry = {\n id,\n name: def?.Identity?.DisplayName ?? id,\n rarity: def?.Classification?.RarityID,\n className: def?.Classification?.ClassID,\n availability: resolveAvailability(def, character !== undefined),\n level: character?.Level ?? undefined,\n power: character?.Power ?? undefined,\n };\n return { entry, sortOrder: def?.Identity?.SortOrder ?? 999 };\n })\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((x) => x.entry);\n\n return { entries, loading };\n}\n\nconst grid: CSSProperties = {\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n};\n\nconst statusStyle: Record<Availability, CSSProperties> = {\n owned: {\n color: \"#ffffff\",\n background: \"#052e7e\",\n border: \"1px solid #1e59c8\",\n },\n available: {\n color: \"#cfe0ff\",\n background: \"#063073\",\n border: \"1px solid #3b78e0\",\n },\n locked: {\n color: \"#a9c6ff\",\n background: \"#04276b\",\n border: \"1px solid #1e59c8\",\n },\n};\n\nfunction card(availability: Availability, selected: boolean): CSSProperties {\n return {\n textAlign: \"left\",\n borderRadius: 12,\n padding: 12,\n cursor: \"pointer\",\n ...statusStyle[availability],\n ...(selected ? { border: \"2px solid #ffd479\" } : {}),\n };\n}\n\nexport function rosterStatusLine(entry: RosterEntry): string {\n switch (entry.availability) {\n case \"owned\":\n return `Lv ${entry.level ?? 0} · Power ${entry.power ?? 0}`;\n case \"available\":\n return \"Available\";\n case \"locked\":\n return \"Locked\";\n }\n}\n\nexport function CharacterRoster({\n selectedID,\n onSelect,\n onSelectedChange,\n}: {\n selectedID: string | null;\n onSelect: (entry: RosterEntry) => void;\n /**\n * The selected character changed under the player (a level-up, an unlock): the scene redraws its\n * card. Without it the Phaser card kept the level it had when the character was picked.\n */\n onSelectedChange?: (entry: RosterEntry) => void;\n}): ReactNode {\n const { entries, loading } = useRoster();\n const selected = entries.find((e) => e.id === selectedID);\n const selectedKey = selected\n ? `${selected.id}|${selected.availability}|${rosterStatusLine(selected)}`\n : \"\";\n useEffect(() => {\n if (selected) onSelectedChange?.(selected);\n // Keyed on what the card shows, not on the entry object (a new one every render).\n }, [selectedKey]);\n if (loading) return <div style={{ opacity: 0.6 }}>Loading characters…</div>;\n if (entries.length === 0)\n return <div style={{ opacity: 0.6 }}>No characters configured.</div>;\n\n return (\n <div style={grid}>\n {entries.map((entry) => (\n <button\n key={entry.id}\n type=\"button\"\n style={card(entry.availability, entry.id === selectedID)}\n onClick={() => {\n onSelect(entry);\n }}\n >\n <div style={{ fontWeight: 700, marginBottom: 4 }}>{entry.name}</div>\n <div style={{ fontSize: 11, opacity: 0.7 }}>\n {[entry.rarity, entry.className].filter(Boolean).join(\" · \")}\n </div>\n <div style={{ fontSize: 13, marginTop: 6 }}>\n {rosterStatusLine(entry)}\n </div>\n </button>\n ))}\n </div>\n );\n}\n"
75
75
  },
76
76
  {
77
77
  "path": "components/CurrencyHud.tsx",
@@ -95,7 +95,7 @@
95
95
  },
96
96
  {
97
97
  "path": "data/characterConfig.ts",
98
- "content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface PriceOption {\n Cost?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\n ValueCurve?: ScalarCurveSpec;\n /** Cost curve; the step is the TARGET level, numbered from 1. */\n CostCurve?: ScalarCurveSpec;\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\n PriceOptions?: Record<string, PriceOption>;\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n /**\n * Skins (alternative looks). Each skin IS a catalog item; wearing it binds a copy to the\n * reserved equipment key \"@skin\" (`SKIN_SLOT` in @idosgames/core). Full shape: CharacterSkins.\n */\n Skins?: {\n DefaultSkinID?: string;\n Definitions?: Record<\n string,\n {\n SkinID?: string;\n Identity?: {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number;\n AssetPaths?: Record<string, string>;\n };\n Item?: { CatalogID?: string; ItemID?: string };\n Requirements?: { MinCharacterLevel?: number };\n }\n >;\n };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n return client.data.config.getSection<CharacterConfig>(\"Character\") ?? {};\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return (\n Object.entries(slots)\n // \"@…\" keys are engine-reserved (the worn skin lives under \"@skin\"); the server refuses\n // gear in them, so a slot declared that way by mistake must not render as a gear slot.\n .filter(([slotID]) => !slotID.startsWith(\"@\"))\n .map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }))\n );\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n valueCurve?: ScalarCurveSpec;\n costCurve?: ScalarCurveSpec;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\n const option = Object.values(rule.PriceOptions ?? {})[0];\n const entry = option?.Cost?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n valueCurve: rule.ValueCurve,\n costCurve: rule.CostCurve,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/**\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\n * server does it in `PvPBattleEngine.CalculateStats`.\n *\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\n * every stat was displayed one step behind what the battle actually used.\n */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\n}\n\n/**\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\n * and the total is rounded UP once — the platform's single rounding convention. The old\n * `Math.round` here disagreed with the server by one unit on half the levels.\n */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n return roundAmount(\n stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1),\n );\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
98
+ "content": "import type { IDosGamesClient, ScalarCurveSpec } from \"@idosgames/core\";\nimport { curveMultiplier, evaluateCurve, roundAmount } from \"@idosgames/core\";\n\n// Lightweight, hand-written views over the (opaque) server `Character` config section.\n// Shapes mirror what the live backend returns (titleID URLV9SUP): per-character definitions +\n// shared Stats/Equipment presets referenced through a Presets binding (merge-by-key semantics —\n// see Core/Presets/Models/PresetBinding.cs on the backend).\n\nexport interface PresetBinding {\n PresetID?: string;\n Remove?: string[];\n}\n\nexport interface EquipmentSlotRule {\n SlotID?: string;\n MinCharacterLevel?: number;\n AllowedItemTags?: string[];\n AllowedRarityIDs?: string[];\n MaxItemLevel?: number;\n MinItemLevel?: number;\n}\n\nexport interface EquipmentPreset {\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n}\n\nexport interface PriceOption {\n Cost?: {\n Standard?: { Entries?: { Amount?: number; CurrencyID?: string }[] };\n };\n}\n\nexport interface StatDefinition {\n StatID?: string;\n DisplayName?: string;\n MaxLevel?: number;\n BaseStatValue?: number;\n /** Growth of the value over the stat's own level; the step is numbered from **0**. */\n ValueCurve?: ScalarCurveSpec;\n /** Cost curve; the step is the TARGET level, numbered from 1. */\n CostCurve?: ScalarCurveSpec;\n /** Ways to pay; key = the option id. Replaced the removed `BaseCostResource`. */\n PriceOptions?: Record<string, PriceOption>;\n}\n\nexport interface StatsPreset {\n Stats?: Record<string, StatDefinition>;\n}\n\nexport interface CharacterPresetBindings {\n Stats?: PresetBinding;\n Levels?: PresetBinding;\n Equipment?: PresetBinding;\n}\n\nexport interface CharacterDefinition {\n CharacterID?: string;\n Presets?: CharacterPresetBindings;\n Equipment?: { Slots?: Record<string, EquipmentSlotRule> };\n Stats?: Record<string, StatDefinition>;\n Classification?: { ClassID?: string; RarityID?: string };\n Identity?: { DisplayName?: string; Description?: string; SortOrder?: number };\n Unlock?: { UnlockedByDefault?: boolean };\n /**\n * Skins (alternative looks). Each skin IS a catalog item; wearing it binds a copy to the\n * reserved equipment key \"@skin\" (`SKIN_SLOT` in @idosgames/core). Full shape: CharacterSkins.\n */\n Skins?: {\n DefaultSkinID?: string;\n Definitions?: Record<\n string,\n {\n SkinID?: string;\n Identity?: {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number;\n AssetPaths?: Record<string, string>;\n };\n Item?: { CatalogID?: string; ItemID?: string };\n Requirements?: { MinCharacterLevel?: number };\n }\n >;\n };\n}\n\nexport interface CharacterPresetRegistry {\n Stats?: Record<string, StatsPreset>;\n Equipment?: Record<string, EquipmentPreset>;\n}\n\nexport interface CharacterConfig {\n Definitions?: Record<string, CharacterDefinition>;\n Presets?: CharacterPresetRegistry;\n}\n\nexport function readCharacterConfig(client: IDosGamesClient): CharacterConfig {\n // Fetched definitions first, else the section that came with the title config at login.\n return (\n client.data.config.getSection<CharacterConfig>(\"Character\") ??\n ((\n client.data.config.titlePublicConfiguration as Record<\n string,\n unknown\n > | null\n )?.[\"Character\"] as CharacterConfig | undefined) ??\n {}\n );\n}\n\nexport interface SlotInfo {\n slotID: string;\n minCharacterLevel: number;\n}\n\n/**\n * Equipment slots for a character: preset (via Presets.Equipment) merged with inline\n * Equipment.Slots by SlotID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveSlots(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): SlotInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const presetSlots = binding?.PresetID\n ? config.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n const slots = mergeByKey(presetSlots, def.Equipment?.Slots, binding?.Remove);\n return (\n Object.entries(slots)\n // \"@…\" keys are engine-reserved (the worn skin lives under \"@skin\"); the server refuses\n // gear in them, so a slot declared that way by mistake must not render as a gear slot.\n .filter(([slotID]) => !slotID.startsWith(\"@\"))\n .map(([slotID, rule]) => ({\n slotID,\n minCharacterLevel: rule.MinCharacterLevel ?? 0,\n }))\n );\n}\n\nexport interface StatInfo {\n statID: string;\n displayName: string;\n maxLevel?: number;\n baseValue: number;\n valueCurve?: ScalarCurveSpec;\n costCurve?: ScalarCurveSpec;\n costAmount: number;\n costCurrency?: string;\n}\n\n/**\n * Upgradable stats for a character: preset (via Presets.Stats) merged with inline Stats by\n * StatID (preset base + inline override/add, Remove drops keys).\n */\nexport function resolveStats(\n config: CharacterConfig,\n def: CharacterDefinition | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const presetStats = binding?.PresetID\n ? config.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const stats = mergeByKey(presetStats, def.Stats, binding?.Remove);\n return Object.entries(stats).map(([statID, rule]) => {\n // The first option's first entry, mirroring the server's \"default cost\" for shop-like\n // displays (`PriceOptionSelector.DefaultCost`): the player has not chosen a way to pay yet.\n const option = Object.values(rule.PriceOptions ?? {})[0];\n const entry = option?.Cost?.Standard?.Entries?.[0];\n return {\n statID,\n displayName: rule.DisplayName ?? statID,\n maxLevel: rule.MaxLevel,\n baseValue: rule.BaseStatValue ?? 0,\n valueCurve: rule.ValueCurve,\n costCurve: rule.CostCurve,\n costAmount: entry?.Amount ?? 0,\n costCurrency: entry?.CurrencyID,\n };\n });\n}\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset -> inline as-is. */\nfunction mergeByKey<T>(\n preset: Record<string, T> | undefined,\n inline: Record<string, T> | undefined,\n remove: string[] | undefined,\n): Record<string, T> {\n if (!preset) return inline ?? {};\n const result: Record<string, T> = { ...preset, ...inline };\n for (const key of remove ?? []) delete result[key];\n return result;\n}\n\n/**\n * Effective stat value at a given level — the shared curve, evaluated exactly as the\n * server does it in `PvPBattleEngine.CalculateStats`.\n *\n * The step is the stat level with **firstStep = 0**: a stat the player never upgraded is\n * level 0 and is worth the plain base. This used to be a third hand-written copy of the\n * formula here, and it was wrong in two ways at once — it assumed the multiplicative\n * shape (the server's is whatever the publisher configured) and counted from level 1, so\n * every stat was displayed one step behind what the battle actually used.\n */\nexport function computeStatValue(stat: StatInfo, level: number): number {\n return evaluateCurve(stat.valueCurve, stat.baseValue, level, 0);\n}\n\n/**\n * Cost to upgrade a stat TO `nextLevel`. The step is the TARGET level, numbered from 1,\n * and the total is rounded UP once — the platform's single rounding convention. The old\n * `Math.round` here disagreed with the server by one unit on half the levels.\n */\nexport function computeUpgradeCost(stat: StatInfo, nextLevel: number): number {\n if (stat.costAmount <= 0) return 0;\n return roundAmount(\n stat.costAmount * curveMultiplier(stat.costCurve, nextLevel, 1),\n );\n}\n\nexport type Availability = \"owned\" | \"available\" | \"locked\";\n\n/** owned = server has a CharacterModel; available = unlocked-by-default but not yet instantiated\n * (first level-up creates the model); locked = must be unlocked. */\nexport function resolveAvailability(\n def: CharacterDefinition | undefined,\n hasModel: boolean,\n): Availability {\n if (hasModel) return \"owned\";\n if (def?.Unlock?.UnlockedByDefault) return \"available\";\n return \"locked\";\n}\n"
99
99
  },
100
100
  {
101
101
  "path": "env.ts",
@@ -127,7 +127,7 @@
127
127
  },
128
128
  {
129
129
  "path": "RootPanel.tsx",
130
- "content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\nimport { FULL_CHROME, type IdleChrome } from \"./chrome\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\nexport interface IdleRpgPanelOptions {\n /** Which shared chrome to draw in the dock (the rest is drawn by a shared-UI provider). */\n chrome?: IdleChrome;\n /** Called after a successful level-up — `setup()` turns it into an event for other modules. */\n onCharacterUpgraded?: (characterId: string, level: number) => void;\n}\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n options: IdleRpgPanelOptions = {},\n): ComponentType {\n const chrome = options.chrome ?? FULL_CHROME;\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout\n chrome={chrome}\n onCharacterUpgraded={options.onCharacterUpgraded}\n />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout({\n chrome,\n onCharacterUpgraded,\n}: {\n chrome: IdleChrome;\n onCharacterUpgraded?: (characterId: string, level: number) => void;\n}): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n };\n\n return (\n <div style={dockStyle}>\n {chrome.balances && <CurrencyHud />}\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster selectedID={selected} onSelect={onSelect} />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail\n characterID={selected}\n onUpgraded={onCharacterUpgraded}\n />\n <EquipmentPanel characterID={selected} />\n {chrome.wallet && <WalletPanel />}\n </div>\n </div>\n {chrome.status && <StatusBar />}\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.92)\",\n borderLeft: \"1px solid #1a4fb8\",\n};\nconst columnsStyle: CSSProperties = {\n display: \"flex\",\n gap: 16,\n alignItems: \"flex-start\",\n marginTop: 8,\n};\nconst detailColumnStyle: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n width: 280,\n flex: \"0 0 280px\",\n};\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
130
+ "content": "import {\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport {\n IdleRpgControllerProvider,\n useIdleRpgController,\n} from \"./react/controller\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { IdleRpgController } from \"./game/IdleRpgController\";\nimport { CurrencyHud } from \"./components/CurrencyHud\";\nimport {\n CharacterRoster,\n rosterStatusLine,\n type RosterEntry,\n} from \"./components/CharacterRoster\";\nimport { CharacterDetail } from \"./components/CharacterDetail\";\nimport { EquipmentPanel } from \"./components/EquipmentPanel\";\nimport { StatusBar } from \"./components/StatusBar\";\nimport { WalletPanel } from \"./components/WalletPanel\";\nimport { FULL_CHROME, type IdleChrome } from \"./chrome\";\n\n// The module's whole UI as a single overlay panel. The host already provides the client + status\n// contexts (mountHost wraps the tree); this panel adds the module's own controller context once the\n// Phaser scene has published its controller into the shared box. It docks to the right so the\n// full-bleed Phaser character card stays visible on the left.\n\nexport interface IdleRpgPanelOptions {\n /** Which shared chrome to draw in the dock (the rest is drawn by a shared-UI provider). */\n chrome?: IdleChrome;\n /** Called after a successful level-up — `setup()` turns it into an event for other modules. */\n onCharacterUpgraded?: (characterId: string, level: number) => void;\n}\n\n/** Builds the panel component bound to the controller published by this module's scene. */\nexport function makeIdleRpgRootPanel(\n box: ControllerBox<IdleRpgController>,\n options: IdleRpgPanelOptions = {},\n): ComponentType {\n const chrome = options.chrome ?? FULL_CHROME;\n return function IdleRpgRootPanel(): ReactNode {\n const controller = useSyncExternalStore(box.subscribe, box.get);\n if (!controller) {\n return <div style={loadingStyle}>Loading RPG…</div>;\n }\n return (\n <IdleRpgControllerProvider controller={controller}>\n <Layout\n chrome={chrome}\n onCharacterUpgraded={options.onCharacterUpgraded}\n />\n </IdleRpgControllerProvider>\n );\n };\n}\n\nfunction Layout({\n chrome,\n onCharacterUpgraded,\n}: {\n chrome: IdleChrome;\n onCharacterUpgraded?: (characterId: string, level: number) => void;\n}): ReactNode {\n const controller = useIdleRpgController();\n const [selected, setSelected] = useState<string | null>(null);\n\n const show = (entry: RosterEntry): void =>\n controller.selectCharacter({\n name: entry.name,\n rarity: entry.rarity,\n className: entry.className,\n statusLine: rosterStatusLine(entry),\n owned: entry.availability === \"owned\",\n });\n const onSelect = (entry: RosterEntry): void => {\n setSelected(entry.id);\n show(entry);\n };\n\n return (\n <div style={dockStyle}>\n {chrome.balances && <CurrencyHud />}\n <div style={columnsStyle}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <CharacterRoster\n selectedID={selected}\n onSelect={onSelect}\n onSelectedChange={show}\n />\n </div>\n <div style={detailColumnStyle}>\n <CharacterDetail\n characterID={selected}\n onUpgraded={onCharacterUpgraded}\n />\n <EquipmentPanel characterID={selected} />\n {chrome.wallet && <WalletPanel />}\n </div>\n </div>\n {chrome.status && <StatusBar />}\n </div>\n );\n}\n\nconst dockStyle: CSSProperties = {\n position: \"absolute\",\n top: 0,\n right: 0,\n bottom: 0,\n width: \"min(560px, 62%)\",\n boxSizing: \"border-box\",\n padding: 16,\n overflow: \"auto\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n background: \"rgba(4,32,90,0.92)\",\n borderLeft: \"1px solid #1a4fb8\",\n};\nconst columnsStyle: CSSProperties = {\n display: \"flex\",\n gap: 16,\n alignItems: \"flex-start\",\n marginTop: 8,\n};\nconst detailColumnStyle: CSSProperties = {\n display: \"flex\",\n flexDirection: \"column\",\n gap: 12,\n width: 280,\n flex: \"0 0 280px\",\n};\nconst loadingStyle: CSSProperties = {\n position: \"absolute\",\n top: 16,\n right: 16,\n opacity: 0.6,\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n};\n"
131
131
  },
132
132
  {
133
133
  "path": "scene.ts",
@@ -32,12 +32,12 @@
32
32
  "version": "0.1.0"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.19.1",
36
- "@idosgames/module-sdk": "0.8.0",
37
- "@idosgames/react": "0.4.0",
35
+ "@idosgames/core": "0.21.0",
36
+ "@idosgames/module-sdk": "0.8.2",
37
+ "@idosgames/react": "0.4.3",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "da42d93b3d21d21109e0f35338c349042dbb01ef442701439dc1d9d4ddbff98d",
40
+ "contentHash": "71f41012c37839b7d4e94c6ed3759118990408da9615743bd27282ca137e49ab",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  {
51
51
  "path": "InventoryScreen.tsx",
52
- "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n Tabs,\n outlined,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n categoryOf,\n isChestOrKey,\n ownedItems,\n type Category,\n type InventoryView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// What the player owns, from the SDK's cached inventory (every purchase and claim updates it — no\n// request of its own). Tabs by category, rarest first; a tap opens the item card. Equipment hands off\n// to the heroes screen, a chest or a key to the chests screen (the lootboxes system).\n\nexport function makeInventoryScreen(features: FeatureRegistry) {\n return function InventoryScreen(_: FeatureScreenProps): ReactNode {\n return <Inventory features={features} />;\n };\n}\n\nfunction Inventory({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const [tab, setTab] = useState<Category>(\"all\");\n const [open, setOpen] = useState<string | null>(null);\n\n // Instances of unstackable items (levels, equipped) come with the full inventory read only.\n useEffect(() => {\n void client.user.getUserInventory();\n }, [client]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n const owned = ownedItems(inventory, catalog.items);\n const shown =\n tab === \"all\"\n ? owned\n : owned.filter((o) => categoryOf(catalog.items.get(o.itemID)) === tab);\n const tile = layout === \"desktop\" ? 100 : layout === \"tablet\" ? 90 : 76;\n\n const tabs: Array<{ id: Category; label: string }> = [\n { id: \"all\", label: t(\"all\") },\n { id: \"equipment\", label: t(\"equipment\") },\n { id: \"consumable\", label: t(\"consumable\") },\n { id: \"other\", label: t(\"other\") },\n ];\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n {catalog.items.size === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noItems\")} />\n ) : shown.length === 0 ? (\n <EmptyState\n glyph=\"chest\"\n text={t(\"empty\")}\n action={\n features.get(\"store\") ? (\n <Button onClick={() => features.open(\"store\")}>\n {t(\"toShop\")}\n </Button>\n ) : undefined\n }\n />\n ) : (\n <div\n data-resource-counter=\"inventory\"\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${tile + 8}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n justifyItems: \"center\",\n }}\n >\n {shown.map((o, i) => (\n <div\n key={o.itemID}\n data-resource-counter={`item:${o.itemID}`}\n style={stagger(i)}\n >\n <ItemTile\n itemID={o.itemID}\n count={o.count}\n level={o.instances[0]?.level}\n size={tile}\n onClick={() => setOpen(o.itemID)}\n />\n </div>\n ))}\n </div>\n )}\n {open ? (\n <ItemCard\n itemID={open}\n owned={owned.find((o) => o.itemID === open)}\n canOpenChest={\n features.get(\"lootboxes\")?.available === true &&\n isChestOrKey(catalog.items.get(open))\n }\n canEquip={\n features.get(\"character\") !== undefined &&\n categoryOf(catalog.items.get(open)) === \"equipment\"\n }\n onEquip={(instanceID) =>\n features.open(\"character\", {\n itemID: open,\n itemInstanceID: instanceID,\n })\n }\n onOpenChest={() => features.open(\"lootboxes\", { itemID: open })}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction ItemCard({\n itemID,\n owned,\n canOpenChest,\n canEquip,\n onEquip,\n onOpenChest,\n onClose,\n}: {\n itemID: string;\n owned: ReturnType<typeof ownedItems>[number] | undefined;\n canOpenChest: boolean;\n canEquip: boolean;\n onEquip: (instanceID: string | undefined) => void;\n onOpenChest: () => void;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const def = catalog.items.get(itemID);\n const rarity = def?.Metadata?.RarityID ?? \"Common\";\n const color = catalog.rarityColor(rarity);\n const description = catalog.localize(def?.Description);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div\n className=\"idos-glow\"\n style={{\n width: 120,\n height: 120,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({ kind: \"item\", id: itemID, amount: 1 })}\n size={78}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 15 }}>\n {t(\"rarity\")}: {rarity}\n </div>\n {description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {description}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"quantity\")}: {owned?.count ?? 0}\n {owned?.instances[0] && owned.instances[0].level > 0\n ? ` · ${t(\"level\")} ${owned.instances[0].level}`\n : \"\"}\n </div>\n </div>\n {canEquip ? (\n <CloseThen onClick={() => onEquip(owned?.instances[0]?.id)} tone=\"blue\">\n {t(\"equip\")}\n </CloseThen>\n ) : null}\n {canOpenChest ? (\n <CloseThen onClick={onOpenChest} tone=\"green\">\n {t(\"open\")}\n </CloseThen>\n ) : null}\n </Popup>\n );\n}\n\nfunction CloseThen({\n onClick,\n tone,\n children,\n}: {\n onClick: () => void;\n tone: \"blue\" | \"green\";\n children: ReactNode;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone={tone}\n onClick={() => {\n close();\n onClick();\n }}\n >\n {children}\n </Button>\n );\n}\n"
52
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n Tabs,\n outlined,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n categoryOf,\n isChestOrKey,\n ownedItems,\n type Category,\n type InventoryView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// What the player owns, from the SDK's cached inventory (every purchase and claim updates it — no\n// request of its own). Tabs by category, rarest first; a tap opens the item card. Equipment hands off\n// to the heroes screen, a chest or a key to the chests screen (the lootboxes system).\n\nexport function makeInventoryScreen(features: FeatureRegistry) {\n return function InventoryScreen(_: FeatureScreenProps): ReactNode {\n return <Inventory features={features} />;\n };\n}\n\nfunction Inventory({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const [tab, setTab] = useState<Category>(\"all\");\n const [open, setOpen] = useState<string | null>(null);\n\n // The inventory (instances included) comes with the login state and every reply keeps it fresh;\n // opening the screen re-checks it through the central cache (a gift, another device).\n useEffect(() => {\n void client.cache.ensureState([\"InventoryV2\"], { maxAgeMs: 60_000 });\n }, [client]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n const owned = ownedItems(inventory, catalog.items);\n const shown =\n tab === \"all\"\n ? owned\n : owned.filter((o) => categoryOf(catalog.items.get(o.itemID)) === tab);\n const tile = layout === \"desktop\" ? 100 : layout === \"tablet\" ? 90 : 76;\n\n const tabs: Array<{ id: Category; label: string }> = [\n { id: \"all\", label: t(\"all\") },\n { id: \"equipment\", label: t(\"equipment\") },\n { id: \"consumable\", label: t(\"consumable\") },\n { id: \"other\", label: t(\"other\") },\n ];\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n {catalog.items.size === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noItems\")} />\n ) : shown.length === 0 ? (\n <EmptyState\n glyph=\"chest\"\n text={t(\"empty\")}\n action={\n features.get(\"store\") ? (\n <Button onClick={() => features.open(\"store\")}>\n {t(\"toShop\")}\n </Button>\n ) : undefined\n }\n />\n ) : (\n <div\n data-resource-counter=\"inventory\"\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${tile + 8}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n justifyItems: \"center\",\n }}\n >\n {shown.map((o, i) => (\n <div\n key={o.itemID}\n data-resource-counter={`item:${o.itemID}`}\n style={stagger(i)}\n >\n <ItemTile\n itemID={o.itemID}\n count={o.count}\n level={o.instances[0]?.level}\n size={tile}\n onClick={() => setOpen(o.itemID)}\n />\n </div>\n ))}\n </div>\n )}\n {open ? (\n <ItemCard\n itemID={open}\n owned={owned.find((o) => o.itemID === open)}\n canOpenChest={\n features.get(\"lootboxes\")?.available === true &&\n isChestOrKey(catalog.items.get(open))\n }\n canEquip={\n features.get(\"character\") !== undefined &&\n categoryOf(catalog.items.get(open)) === \"equipment\"\n }\n onEquip={(instanceID) =>\n features.open(\"character\", {\n itemID: open,\n itemInstanceID: instanceID,\n })\n }\n onOpenChest={() => features.open(\"lootboxes\", { itemID: open })}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction ItemCard({\n itemID,\n owned,\n canOpenChest,\n canEquip,\n onEquip,\n onOpenChest,\n onClose,\n}: {\n itemID: string;\n owned: ReturnType<typeof ownedItems>[number] | undefined;\n canOpenChest: boolean;\n canEquip: boolean;\n onEquip: (instanceID: string | undefined) => void;\n onOpenChest: () => void;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const def = catalog.items.get(itemID);\n const rarity = def?.Metadata?.RarityID ?? \"Common\";\n const color = catalog.rarityColor(rarity);\n const description = catalog.localize(def?.Description);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div\n className=\"idos-glow\"\n style={{\n width: 120,\n height: 120,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({ kind: \"item\", id: itemID, amount: 1 })}\n size={78}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 15 }}>\n {t(\"rarity\")}: {rarity}\n </div>\n {description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {description}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"quantity\")}: {owned?.count ?? 0}\n {owned?.instances[0] && owned.instances[0].level > 0\n ? ` · ${t(\"level\")} ${owned.instances[0].level}`\n : \"\"}\n </div>\n </div>\n {canEquip ? (\n <CloseThen onClick={() => onEquip(owned?.instances[0]?.id)} tone=\"blue\">\n {t(\"equip\")}\n </CloseThen>\n ) : null}\n {canOpenChest ? (\n <CloseThen onClick={onOpenChest} tone=\"green\">\n {t(\"open\")}\n </CloseThen>\n ) : null}\n </Popup>\n );\n}\n\nfunction CloseThen({\n onClick,\n tone,\n children,\n}: {\n onClick: () => void;\n tone: \"blue\" | \"green\";\n children: ReactNode;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone={tone}\n onClick={() => {\n close();\n onClick();\n }}\n >\n {children}\n </Button>\n );\n}\n"
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
@@ -35,12 +35,12 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.19.1",
39
- "@idosgames/module-sdk": "0.8.0",
40
- "@idosgames/react": "0.4.0",
38
+ "@idosgames/core": "0.21.0",
39
+ "@idosgames/module-sdk": "0.8.2",
40
+ "@idosgames/react": "0.4.3",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "f3ed6702817b2217cee7bf25accd5dbb138d6657bb7f15edcc4cc8c03a26b01f",
43
+ "contentHash": "249258bc510a42f88eb82c8c63c1d31c9def54246f9856f549b99358e2f6fe04",
44
44
  "files": [
45
45
  {
46
46
  "path": "i18n.ts",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  {
62
62
  "path": "ReferralScreen.tsx",
63
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\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 errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
63
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs.current) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs.current) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n }, [client]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
64
64
  },
65
65
  {
66
66
  "path": "module.meta.json",
@@ -36,12 +36,12 @@
36
36
  "version": "0.1.1"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.19.1",
40
- "@idosgames/module-sdk": "0.8.0",
41
- "@idosgames/react": "0.4.0",
39
+ "@idosgames/core": "0.21.0",
40
+ "@idosgames/module-sdk": "0.8.2",
41
+ "@idosgames/react": "0.4.3",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "6d86641300a3b71c007d01eb4bc7ac345eef9ca99e99dda75a40d252c50c23f3",
44
+ "contentHash": "5d7fccefb883ee98826e3d153614e26a62d92595bfe932d29c1d5213b6113672",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  {
55
55
  "path": "LeaderboardScreen.tsx",
56
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"blue\"),\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n ...(mine\n ? toneSurface(\"blue\")\n : { background: panel.background }),\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? v.wellSoft,\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
56
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n // The live place: the table when the player is in it — `LastKnownRank` of the progress is the place\n // at the last recount, 0 for a player who has not been recounted yet (the table said #1 while the\n // card said \"—\").\n const myRank =\n users.find((u) => u.UserID === me)?.Rank || progress?.LastKnownRank || null;\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"blue\"),\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat label={t(\"myRank\")} value={myRank ? `#${myRank}` : \"—\"} />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={myRank}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n ...(mine\n ? toneSurface(\"blue\")\n : { background: panel.background }),\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? v.wellSoft,\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "model.ts",