@idosgames/mcp 0.1.37 → 0.1.39

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 (43) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +2235 -27
  3. package/package.json +4 -1
  4. package/registry/host.json +3 -3
  5. package/registry/index.json +309 -156
  6. package/registry/modules/ai.json +7 -7
  7. package/registry/modules/board-game.json +14 -14
  8. package/registry/modules/boosts.json +3 -3
  9. package/registry/modules/character.json +32 -15
  10. package/registry/modules/chat.json +3 -3
  11. package/registry/modules/collections.json +7 -7
  12. package/registry/modules/coop-events.json +7 -7
  13. package/registry/modules/craft.json +3 -3
  14. package/registry/modules/deals.json +7 -7
  15. package/registry/modules/events.json +8 -8
  16. package/registry/modules/friends.json +9 -9
  17. package/registry/modules/hero-run.json +497 -0
  18. package/registry/modules/idle-rpg.json +70 -18
  19. package/registry/modules/inventory.json +3 -3
  20. package/registry/modules/invite.json +7 -7
  21. package/registry/modules/leaderboard.json +10 -10
  22. package/registry/modules/lootboxes.json +7 -7
  23. package/registry/modules/mailbox.json +8 -8
  24. package/registry/modules/marketplace.json +8 -8
  25. package/registry/modules/mini-game.json +8 -8
  26. package/registry/modules/multiplayer.json +7 -7
  27. package/registry/modules/premium.json +7 -7
  28. package/registry/modules/profile.json +7 -7
  29. package/registry/modules/pvp.json +7 -7
  30. package/registry/modules/quests.json +8 -8
  31. package/registry/modules/rewards.json +8 -8
  32. package/registry/modules/season.json +7 -7
  33. package/registry/modules/store.json +7 -7
  34. package/registry/modules/tutorials.json +7 -7
  35. package/registry/modules/video-studio.json +228 -0
  36. package/registry/modules/voxelcraft.json +42 -125
  37. package/registry/modules/workshop.json +3 -3
  38. package/registry/skills/hero-run.json +6 -0
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +1 -1
  42. package/registry/skills/video-studio.json +6 -0
  43. package/registry/video.json +9800 -0
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.22.1",
36
- "@idosgames/module-sdk": "0.12.0",
37
- "@idosgames/react": "0.5.2",
35
+ "@idosgames/core": "0.23.0",
36
+ "@idosgames/module-sdk": "0.13.0",
37
+ "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "0b02bc09a1a29ecb6d08fa281dfe316b01f8f341350ba27f386a05572827700e",
@@ -32,15 +32,15 @@
32
32
  "name": "iDos Games",
33
33
  "url": "https://idosgames.com"
34
34
  },
35
- "version": "0.1.2"
35
+ "version": "0.1.3"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.22.1",
39
- "@idosgames/module-sdk": "0.12.0",
40
- "@idosgames/react": "0.5.2",
38
+ "@idosgames/core": "0.23.0",
39
+ "@idosgames/module-sdk": "0.13.0",
40
+ "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "7d314ec2a1801e22dbe5b2e2df731e7a582f8efb9cca0328de5e30b52cc27047",
43
+ "contentHash": "5646a986e8436be6b36dbb5e878d06de1c7cfd4c925f0af77b41be906115083d",
44
44
  "files": [
45
45
  {
46
46
  "path": "i18n.ts",
@@ -60,11 +60,11 @@
60
60
  },
61
61
  {
62
62
  "path": "ReferralScreen.tsx",
63
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type { FeatureScreenProps, ModuleContext } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n TextInput,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function watchReferral(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n let defs: ReferralDefinitions | null = null;\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n });\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <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 <TextInput\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
63
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type { FeatureScreenProps, ModuleContext } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n TextInput,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function watchReferral(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n let defs: ReferralDefinitions | null = null;\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n });\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <>\n {/* a phone: one column; a tablet or a PC: my code | the rewards for friends */}\n <Columns min={380} gap={12}>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>\n {t(\"joined\")}\n </div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n </div>\n <div style={{ display: \"grid\", gap: 12, alignContent: \"start\" }}>\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n </div>\n </Columns>\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <TextInput\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
64
64
  },
65
65
  {
66
66
  "path": "module.meta.json",
67
- "content": "{\n \"id\": \"invite\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature — always shown in the lobby; while the title has invites off (Referral.IsEnabled, off by default) the screen says so and makes no request. Its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
67
+ "content": "{\n \"id\": \"invite\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature — always shown in the lobby; while the title has invites off (Referral.IsEnabled, off by default) the screen says so and makes no request. Its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
68
68
  }
69
69
  ]
70
70
  }
@@ -33,15 +33,15 @@
33
33
  "name": "iDos Games",
34
34
  "url": "https://idosgames.com"
35
35
  },
36
- "version": "0.1.3"
36
+ "version": "0.1.4"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.22.1",
40
- "@idosgames/module-sdk": "0.12.0",
41
- "@idosgames/react": "0.5.2",
39
+ "@idosgames/core": "0.23.0",
40
+ "@idosgames/module-sdk": "0.13.0",
41
+ "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "bbf36dc2922963131f620c9199f259de49fbb4ac3cd6a0d9a24f1f65485b1ee0",
44
+ "contentHash": "fa0f2dfc6084b8c9650f50c14150a8a9e127ccb27f365d5a87e31af126786f11",
45
45
  "files": [
46
46
  {
47
47
  "path": "components/CycleReward.tsx",
@@ -53,11 +53,11 @@
53
53
  },
54
54
  {
55
55
  "path": "components/RankRewardsPopup.tsx",
56
- "content": "import { type ReactNode } from \"react\";\nimport {\n grantLines,\n Icon,\n UMedal,\n UPopup,\n URankRow,\n formatAmount,\n uText,\n useCatalog,\n useStagger,\n} from \"@idosgames/react/ui\";\nimport { rewardForRank, type LeaderboardDefinitionView } from \"../model\";\nimport { t } from \"../i18n\";\n\n// Unity LeaderboardRewardsPopup (\"RANKING REWARDS\"): ListFrame02 rows — gold, silver, bronze for the\n// first three ranges with their medals, blue after — the range on the plate, the reward in the\n// oblique end. The player's own range carries a \"You\" tag.\n\nconst TONES = [\"gold\", \"silver\", \"bronze\"] as const;\n\nexport function 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 const catalog = useCatalog();\n return (\n <UPopup\n title={t(\"rankRewards\")}\n icon=\"trophy\"\n onClose={onClose}\n width={440}\n >\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 <div\n key={r?.Rank ?? i}\n style={{ position: \"relative\", ...stagger(i) }}\n >\n <URankRow\n tone={TONES[i] ?? \"blue\"}\n height={62}\n rightWidth={lines.length > 1 ? 150 : 110}\n right={lines.slice(0, 2).map((l, k) => (\n <span\n key={k}\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 2,\n ...uText(14),\n }}\n >\n <Icon glyph={catalog.iconOf(l)} size={26} />\n {formatAmount(l.amount)}\n </span>\n ))}\n >\n {i < 3 ? (\n <UMedal place={(i + 1) as 1 | 2 | 3} size={44} />\n ) : (\n <span style={{ width: 35 }} />\n )}\n <span style={uText(20)}>#{r?.Rank}</span>\n </URankRow>\n {mineHere ? (\n <span\n style={{\n position: \"absolute\",\n left: -6,\n top: -8,\n padding: \"1px 8px\",\n borderRadius: 8,\n background: \"#ff243d\",\n border: \"2px solid #10172e\",\n ...uText(12),\n }}\n >\n {t(\"you\")}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n </UPopup>\n );\n}\n"
56
+ "content": "import { type ReactNode } from \"react\";\nimport {\n grantLines,\n Icon,\n UMedal,\n UPopup,\n URankRow,\n formatAmount,\n uText,\n useCatalog,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { rewardForRank, type LeaderboardDefinitionView } from \"../model\";\nimport { t } from \"../i18n\";\n\n// Unity LeaderboardRewardsPopup (\"RANKING REWARDS\"): ListFrame02 rows — gold, silver, bronze for the\n// first three ranges with their medals, blue after — the range on the plate, the reward in the\n// oblique end. The player's own range carries a \"You\" tag.\n\nconst TONES = [\"gold\", \"silver\", \"bronze\"] as const;\n\nexport function 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 const catalog = useCatalog();\n return (\n <UPopup\n title={t(\"rankRewards\")}\n icon=\"trophy\"\n onClose={onClose}\n width={440}\n >\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 <div\n key={r?.Rank ?? i}\n style={{ position: \"relative\", ...stagger(i) }}\n >\n <URankRow\n tone={TONES[i] ?? \"blue\"}\n height={62}\n rightWidth={lines.length > 1 ? 150 : 110}\n right={lines.slice(0, 2).map((l, k) => (\n <span\n key={k}\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 2,\n ...uText(14),\n }}\n >\n <Icon glyph={catalog.iconOf(l)} size={26} />\n {formatAmount(l.amount)}\n </span>\n ))}\n >\n {i < 3 ? (\n <UMedal place={(i + 1) as 1 | 2 | 3} size={44} />\n ) : (\n <span style={{ width: 35 }} />\n )}\n <span style={uText(20)}>#{r?.Rank}</span>\n </URankRow>\n {mineHere ? (\n <span\n style={{\n position: \"absolute\",\n left: -6,\n top: -8,\n padding: \"1px 8px\",\n borderRadius: 8,\n background: v.red,\n border: `2px solid ${v.ink}`,\n ...uText(12),\n }}\n >\n {t(\"you\")}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n </UPopup>\n );\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "components/TopList.tsx",
60
- "content": "import { type ReactNode } from \"react\";\nimport { type LeaderboardUserEntry } from \"@idosgames/core\";\nimport {\n Icon,\n UAvatar,\n UFlag,\n UHexRank,\n UMedal,\n URankRow,\n formatAmount,\n uText,\n useStagger,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\n\n// The Unity LeaderboardWindow body: three podium flags (2nd blue on the left, 1st red in the middle,\n// 3rd green on the right — a profile frame, the name, the trophy with the points, the medal), then\n// LeaderboardEntry rows from the 4th place (a hexagon with the place, the profile frame, the name,\n// the trophy and the points in the oblique end); the player's own row is yellow.\n\nconst AVATAR_INNER = [\"#fdae4f\", \"#5fd6ff\", \"#6b8fff\", \"#f5cc47\"];\n\nexport function nameOf(\n entry: LeaderboardUserEntry,\n me: string | undefined,\n): string {\n return (\n entry.PublicProfile?.Username ||\n (entry.UserID === me\n ? t(\"you\")\n : `${t(\"player\")} ${entry.UserID.slice(-4)}`)\n );\n}\n\nexport function Podium({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const at = (place: number) =>\n users.find((u, i) => (u.Rank ?? i + 1) === place) ?? null;\n const slot = (place: 1 | 2 | 3, color: \"blue\" | \"red\" | \"green\") => {\n const entry = at(place);\n const name = entry ? nameOf(entry, me) : \"—\";\n return (\n <div\n style={{\n flex: 1,\n minWidth: 0,\n marginTop: place === 1 ? 0 : 14,\n animation: \"idos-u-pop .45s cubic-bezier(.3,1.6,.5,1) both\",\n animationDelay: `${(place === 1 ? 0 : place === 2 ? 0.1 : 0.2).toFixed(2)}s`,\n }}\n >\n <UFlag color={color} height={place === 1 ? 196 : 182}>\n <div style={{ position: \"relative\" }}>\n <UAvatar\n name={name}\n src={entry?.PublicProfile?.AvatarUrl}\n inner={AVATAR_INNER[place - 1]}\n size={50}\n />\n {entry?.PublicProfile?.Premium ? (\n <span style={{ position: \"absolute\", right: -10, top: -10 }}>\n <Icon glyph=\"crown\" size={20} />\n </span>\n ) : null}\n </div>\n <div\n style={{\n ...uText(14),\n maxWidth: \"88%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n marginTop: 2,\n }}\n >\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n ...uText(13),\n }}\n >\n <Icon glyph=\"trophy\" size={18} />\n {entry ? formatAmount(Number(entry.Score ?? 0)) : \"0\"}\n </div>\n <div style={{ marginTop: 2 }}>\n <UMedal place={place} size={46} />\n </div>\n </UFlag>\n </div>\n );\n };\n return (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n padding: \"0 10px\",\n alignItems: \"flex-start\",\n }}\n >\n {slot(2, \"blue\")}\n {slot(1, \"red\")}\n {slot(3, \"green\")}\n </div>\n );\n}\n\nexport function EntryRow({\n entry,\n place,\n me,\n}: {\n entry: LeaderboardUserEntry;\n place: number;\n me: string | undefined;\n}): ReactNode {\n const mine = entry.UserID === me;\n const name = nameOf(entry, me);\n const level = entry.PublicProfile?.Level;\n return (\n <URankRow\n tone={mine ? \"me\" : \"entry\"}\n height={62}\n rightWidth={116}\n right={\n <>\n <Icon glyph=\"trophy\" size={24} />\n <span style={uText(15)}>\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </>\n }\n >\n <div style={{ position: \"relative\" }}>\n <UHexRank color={mine ? \"#ffdb3d\" : \"#0a7ad9\"} size={42}>\n {place}\n </UHexRank>\n {entry.PublicProfile?.Premium ? (\n <span style={{ position: \"absolute\", left: 10, top: -12 }}>\n <Icon glyph=\"crown\" size={20} />\n </span>\n ) : null}\n </div>\n <UAvatar\n name={name}\n src={entry.PublicProfile?.AvatarUrl}\n inner={AVATAR_INNER[place % AVATAR_INNER.length]}\n size={44}\n />\n <div style={{ minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n ...uText(16),\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n {level ? <span style={uText(11)}>Lv.{level}</span> : null}\n </div>\n </URankRow>\n );\n}\n\nexport function TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n const rest = users.slice(0, 100).filter((u, i) => (u.Rank ?? i + 1) > 3);\n return (\n <div style={{ display: \"grid\", gap: 8, padding: \"0 10px\" }}>\n {rest.map((entry, i) => (\n <div key={entry.UserID} style={stagger(i)}>\n <EntryRow entry={entry} place={entry.Rank ?? i + 4} me={me} />\n </div>\n ))}\n </div>\n );\n}\n"
60
+ "content": "import { type ReactNode } from \"react\";\nimport { type LeaderboardUserEntry } from \"@idosgames/core\";\nimport {\n Columns,\n Icon,\n UAvatar,\n UFlag,\n UMedal,\n formatAmount,\n inked,\n uText,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\n\n// The board's body in the casual sheet's \"Ranking\" look: one strip per player from the first place\n// (medals for the top three), the player's own row gold. `Podium` — the three hanging flags of the\n// earlier Unity look — stays exported for a game that wants it over the list.\n\nconst AVATAR_INNER = [\"#fdae4f\", \"#5fd6ff\", \"#6b8fff\", \"#f5cc47\"];\n\nexport function nameOf(\n entry: LeaderboardUserEntry,\n me: string | undefined,\n): string {\n return (\n entry.PublicProfile?.Username ||\n (entry.UserID === me\n ? t(\"you\")\n : `${t(\"player\")} ${entry.UserID.slice(-4)}`)\n );\n}\n\nexport function Podium({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const at = (place: number) =>\n users.find((u, i) => (u.Rank ?? i + 1) === place) ?? null;\n const slot = (place: 1 | 2 | 3, color: \"blue\" | \"red\" | \"green\") => {\n const entry = at(place);\n const name = entry ? nameOf(entry, me) : \"—\";\n return (\n <div\n style={{\n flex: 1,\n minWidth: 0,\n marginTop: place === 1 ? 0 : 14,\n animation: \"idos-u-pop .45s cubic-bezier(.3,1.6,.5,1) both\",\n animationDelay: `${(place === 1 ? 0 : place === 2 ? 0.1 : 0.2).toFixed(2)}s`,\n }}\n >\n <UFlag color={color} height={place === 1 ? 196 : 182}>\n <div style={{ position: \"relative\" }}>\n <UAvatar\n name={name}\n src={entry?.PublicProfile?.AvatarUrl}\n inner={AVATAR_INNER[place - 1]}\n size={50}\n />\n {entry?.PublicProfile?.Premium ? (\n <span style={{ position: \"absolute\", right: -10, top: -10 }}>\n <Icon glyph=\"crown\" size={20} />\n </span>\n ) : null}\n </div>\n <div\n style={{\n ...uText(14),\n maxWidth: \"88%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n marginTop: 2,\n }}\n >\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 3,\n ...uText(13),\n }}\n >\n <Icon glyph=\"trophy\" size={18} />\n {entry ? formatAmount(Number(entry.Score ?? 0)) : \"0\"}\n </div>\n <div style={{ marginTop: 2 }}>\n <UMedal place={place} size={46} />\n </div>\n </UFlag>\n </div>\n );\n };\n return (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n padding: \"0 10px\",\n alignItems: \"flex-start\",\n }}\n >\n {slot(2, \"blue\")}\n {slot(1, \"red\")}\n {slot(3, \"green\")}\n </div>\n );\n}\n\n/**\n * One row of the casual sheet's \"Ranking\": a full-width strip — the medal for the top three, the\n * place for the rest — the avatar tile, the name, the score big on the right; the player's own row\n * is gold.\n */\nexport function EntryRow({\n entry,\n place,\n me,\n}: {\n entry: LeaderboardUserEntry;\n place: number;\n me: string | undefined;\n}): ReactNode {\n const mine = entry.UserID === me;\n const name = nameOf(entry, me);\n const level = entry.PublicProfile?.Level;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"48px auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 12,\n padding: \"10px 14px 12px\",\n background: mine\n ? `linear-gradient(180deg, color-mix(in srgb, ${v.gold} 60%, #fff) 0%, ${v.gold} 45%, ${v.goldDeep} 100%)`\n : `linear-gradient(180deg, color-mix(in srgb, ${v.panelDeep} 55%, ${v.bgTop}) 0%, color-mix(in srgb, ${v.panelDeep} 40%, ${v.bgTop}) 100%)`,\n borderBottom: `5px solid color-mix(in srgb, ${v.bgBottom} 70%, ${v.ink})`,\n }}\n >\n <div\n style={{ position: \"relative\", display: \"grid\", placeItems: \"center\" }}\n >\n {place >= 1 && place <= 3 ? (\n <UMedal place={place as 1 | 2 | 3} size={46} />\n ) : (\n <span\n style={{\n ...inked,\n fontSize: place > 999 ? 17 : 24,\n color: mine\n ? \"#ffffff\"\n : `color-mix(in srgb, ${v.blue} 45%, #fff)`,\n }}\n >\n {place > 0 ? place : \"—\"}\n </span>\n )}\n {entry.PublicProfile?.Premium ? (\n <span style={{ position: \"absolute\", left: 10, top: -14 }}>\n <Icon glyph=\"crown\" size={20} />\n </span>\n ) : null}\n </div>\n <UAvatar\n name={name}\n src={entry.PublicProfile?.AvatarUrl}\n inner={AVATAR_INNER[place % AVATAR_INNER.length]}\n size={50}\n />\n <div style={{ minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n ...inked,\n fontSize: 18,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n {level ? (\n <span style={{ ...inked, fontSize: 12 }}>Lv.{level}</span>\n ) : null}\n </div>\n <span style={{ ...inked, fontSize: 24 }}>\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n}\n\nexport function TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n // the places run down the first column, then the next — two columns on a PC\n <Columns kind=\"ranked\">\n {users.slice(0, 100).map((entry, i) => (\n <div key={entry.UserID} style={stagger(i)}>\n <EntryRow entry={entry} place={entry.Rank ?? i + 1} me={me} />\n </div>\n ))}\n </Columns>\n );\n}\n"
61
61
  },
62
62
  {
63
63
  "path": "i18n.ts",
@@ -69,7 +69,7 @@
69
69
  },
70
70
  {
71
71
  "path": "LeaderboardScreen.tsx",
72
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type GetLeaderboardResponse,\n type GetMyProgressResponse,\n} from \"@idosgames/core\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Center,\n configSection,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n Spinner,\n U,\n UButton,\n UIconSlider,\n URays,\n UScreen,\n UTabs,\n UTimer,\n UTopBar,\n uText,\n useCatalog,\n useNow,\n useToast,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport {\n MilestonesPopup,\n milestoneProgress,\n} from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { EntryRow, Podium, TopList } from \"./components/TopList\";\n\n// Leaderboards (Unity LeaderboardWindow, the Layer Lab look of Board_Survival): a violet top strip —\n// the dark title bar with my place in the black corner, the orange milestone slider with its timer\n// (tap → the milestone rewards) and the golden trophy over spinning rays (tap → the rank rewards);\n// three podium flags, the entries from the 4th place, the player's own yellow row pinned at the\n// bottom over the boards' pill tabs. A game that has something to PLAY for the score registers a\n// \"play\" feature, and a Play button appears here.\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 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 mine = users.find((u) => u.UserID === me);\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 = mine?.Rank || progress?.LastKnownRank || null;\n const myScore = Number(mine?.Score ?? progress?.CurrentScore ?? 0);\n\n // The slider runs to the next milestone not yet reached (Unity \"13/50\"), past the last — full.\n const earned = milestoneProgress(progress);\n const steps = Object.values(board.Milestones ?? {})\n .map((m) => Number(m?.RequiredProgress ?? 0))\n .filter((n) => n > 0)\n .sort((a, b) => a - b);\n const nextStep = steps.find((n) => n > earned) ?? steps[steps.length - 1];\n const hasRanks = (board.RankRewards?.length ?? 0) > 0;\n const left = countdown(top?.CycleEndUtc, now);\n\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 // Bottom: the player's row (ListFrame01_Yellow), the call to action, the board tabs.\n const footer = (\n <div\n style={{\n background: \"#022b73\",\n borderTop: `3px solid ${U.ink}`,\n }}\n >\n <div style={{ display: \"grid\", gap: 8, padding: \"10px 10px 0\" }}>\n {me ? (\n <EntryRow\n entry={\n mine ?? {\n UserID: me,\n Score: myScore,\n Rank: myRank ?? undefined,\n }\n }\n place={myRank ?? 0}\n me={me}\n />\n ) : null}\n {cta === \"play\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </UButton>\n ) : cta === \"invite\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={20} /> {t(\"invite\")}\n </UButton>\n ) : null}\n </div>\n {boards.length > 1 ? (\n <UTabs\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 ) : (\n <div style={{ height: 10 }} />\n )}\n </div>\n );\n\n return (\n <UScreen footer={footer}>\n {/* ── Top: the title bar, the milestone slider + timer, the rank-rewards trophy ── */}\n <div\n style={{ background: \"#5461ff\", borderBottom: `3px solid ${U.ink}` }}\n >\n <UTopBar\n title={catalog.localize(board.DisplayName ?? id)}\n cornerLabel={t(\"myRank\")}\n corner={myRank ? `#${myRank}` : \"—\"}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: hasRanks ? \"1fr 92px\" : \"1fr\",\n alignItems: \"center\",\n gap: 6,\n padding: \"12px 12px 10px\",\n }}\n >\n <div style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n {nextStep !== undefined ? (\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph=\"trophy\"\n value={Math.min(earned, nextStep)}\n max={nextStep}\n onClick={() => setPopup(\"milestones\")}\n />\n {progress?.HasUnclaimedMilestone ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: \"#ff243d\",\n border: `2px solid ${U.ink}`,\n animation: \"idos-u-attract 1.2s ease-in-out infinite\",\n }}\n />\n ) : null}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {left ? <UTimer>{left}</UTimer> : null}\n {top?.TotalParticipants ? (\n <span style={uText(13)}>\n <Icon glyph=\"friends\" size={16} />{\" \"}\n {formatAmount(Number(top.TotalParticipants))}\n </span>\n ) : null}\n </div>\n </div>\n {hasRanks ? (\n <button\n type=\"button\"\n aria-label={t(\"rankRewards\")}\n onClick={() => setPopup(\"ranks\")}\n className=\"idos-press\"\n style={{\n position: \"relative\",\n width: 92,\n height: 92,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -14 }}>\n <URays size={120} color=\"rgba(255,250,120,.45)\" />\n </span>\n <span\n style={{\n position: \"relative\",\n animation: \"idos-u-float 2.4s ease-in-out infinite\",\n filter: \"drop-shadow(0 4px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"trophy\" size={62} />\n </span>\n </button>\n ) : null}\n </div>\n </div>\n\n {/* ── Body: the podium and the entries ── */}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 12,\n padding: \"14px 0 12px\",\n }}\n >\n {loading ? (\n <Center minHeight={160}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <>\n <Podium users={users} me={me} />\n <TopList users={users} me={me} />\n </>\n )}\n </div>\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 onOpenVip={\n features.get(\"premium\")?.available\n ? (premiumID) => features.open(\"premium\", { premiumID })\n : undefined\n }\n />\n ) : null}\n </UScreen>\n );\n}\n"
72
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type GetLeaderboardResponse,\n type GetMyProgressResponse,\n} from \"@idosgames/core\";\nimport {\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Center,\n configSection,\n countdown,\n EmptyState,\n errorText,\n formatAmount,\n Icon,\n Spinner,\n U,\n UButton,\n UIconSlider,\n UScreen,\n Tabs,\n v,\n UTimer,\n UTopBar,\n uText,\n useCatalog,\n useNow,\n useToast,\n} from \"@idosgames/react/ui\";\nimport { boardAction, enabledBoards, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\nimport {\n MilestonesPopup,\n milestoneProgress,\n} from \"./components/MilestonesPopup\";\nimport { RankRewardsPopup } from \"./components/RankRewardsPopup\";\nimport { EntryRow, TopList } from \"./components/TopList\";\n\n// Leaderboards (Unity LeaderboardWindow, the Layer Lab look of Board_Survival): a violet top strip —\n// the dark title bar with my place in the black corner, the orange milestone slider with its timer\n// (tap → the milestone rewards) and the golden trophy at its right edge (tap → the rank rewards);\n// three podium flags, the entries from the 4th place, the player's own yellow row pinned at the\n// bottom over the boards' pill tabs. A game that has something to PLAY for the score registers a\n// \"play\" feature, and a Play button appears here.\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 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 mine = users.find((u) => u.UserID === me);\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 = mine?.Rank || progress?.LastKnownRank || null;\n const myScore = Number(mine?.Score ?? progress?.CurrentScore ?? 0);\n\n // The slider runs to the next milestone not yet reached (Unity \"13/50\"), past the last — full.\n const earned = milestoneProgress(progress);\n const steps = Object.values(board.Milestones ?? {})\n .map((m) => Number(m?.RequiredProgress ?? 0))\n .filter((n) => n > 0)\n .sort((a, b) => a - b);\n const nextStep = steps.find((n) => n > earned) ?? steps[steps.length - 1];\n const hasRanks = (board.RankRewards?.length ?? 0) > 0;\n const left = countdown(top?.CycleEndUtc, now);\n\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 // Bottom: the player's row (ListFrame01_Yellow), the call to action, the board tabs.\n const footer =\n cta !== null ? (\n <div\n style={{\n background: v.nav,\n borderTop: `3px solid ${U.ink}`,\n overflow: \"hidden\",\n }}\n >\n {/* one button of its own size in the middle — not a bar across the window */}\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 320px)\",\n justifyContent: \"center\",\n gap: 8,\n padding: \"10px 12px\",\n }}\n >\n {cta === \"play\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </UButton>\n ) : cta === \"invite\" ? (\n <UButton\n tone=\"green\"\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={20} /> {t(\"invite\")}\n </UButton>\n ) : null}\n </div>\n </div>\n ) : undefined;\n\n return (\n <UScreen footer={footer}>\n {/* ── Top: the title bar, the milestone slider + timer, the rank-rewards trophy ── */}\n <div\n style={{\n background: `color-mix(in srgb, ${v.bgTop} 85%, #fff)`,\n borderBottom: `3px solid ${U.ink}`,\n }}\n >\n <UTopBar\n title={catalog.localize(board.DisplayName ?? id)}\n cornerLabel={t(\"myRank\")}\n corner={myRank ? `#${myRank}` : \"—\"}\n />\n <div\n style={{\n position: \"relative\",\n display: \"grid\",\n alignItems: \"center\",\n gap: 6,\n // room for the trophy on the right; the trophy itself never sets the row's height\n padding: hasRanks ? \"10px 64px 10px 64px\" : \"10px 12px\",\n }}\n >\n <div style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}>\n {nextStep !== undefined ? (\n <div style={{ width: \"100%\", position: \"relative\" }}>\n <UIconSlider\n glyph=\"trophy\"\n value={Math.min(earned, nextStep)}\n max={nextStep}\n onClick={() => setPopup(\"milestones\")}\n />\n {progress?.HasUnclaimedMilestone ? (\n <span\n style={{\n position: \"absolute\",\n left: 38,\n top: -8,\n width: 18,\n height: 18,\n borderRadius: \"50%\",\n background: v.red,\n border: `2px solid ${U.ink}`,\n animation: \"idos-u-attract 1.2s ease-in-out infinite\",\n }}\n />\n ) : null}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {left ? <UTimer>{left}</UTimer> : null}\n {top?.TotalParticipants ? (\n <span style={uText(13)}>\n <Icon glyph=\"friends\" size={16} />{\" \"}\n {formatAmount(Number(top.TotalParticipants))}\n </span>\n ) : null}\n </div>\n </div>\n {hasRanks ? (\n <button\n type=\"button\"\n aria-label={t(\"rankRewards\")}\n onClick={() => setPopup(\"ranks\")}\n className=\"idos-press\"\n style={{\n // over the row, centred on its right edge: whatever its size, the row keeps the\n // height of the timer line (owner, 2026-10-22: no rays, no taller row)\n position: \"absolute\",\n right: 10,\n top: \"50%\",\n transform: \"translateY(-50%)\",\n width: 48,\n height: 48,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span\n style={{\n animation: \"idos-u-float 2.4s ease-in-out infinite\",\n filter: \"drop-shadow(0 3px 0 rgba(0,0,0,.35))\",\n }}\n >\n <Icon glyph=\"trophy\" size={42} />\n </span>\n </button>\n ) : null}\n </div>\n </div>\n\n {/* ── The boards as tabs (\"All | Hero\"), the player's own gold row, then everyone ── */}\n {boards.length > 1 ? (\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 style={{\n borderRadius: 0,\n borderLeft: \"none\",\n borderRight: \"none\",\n borderTop: \"none\",\n }}\n />\n ) : null}\n {me && !loading ? (\n <EntryRow\n entry={\n mine ?? {\n UserID: me,\n Score: myScore,\n Rank: myRank ?? undefined,\n }\n }\n place={myRank ?? 0}\n me={me}\n />\n ) : null}\n <div\n style={{\n flex: 1,\n display: \"grid\",\n alignContent: \"start\",\n gap: 0,\n padding: \"0 0 12px\",\n }}\n >\n {loading ? (\n <Center minHeight={160}>\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 {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 onOpenVip={\n features.get(\"premium\")?.available\n ? (premiumID) => features.open(\"premium\", { premiumID })\n : undefined\n }\n />\n ) : null}\n </UScreen>\n );\n}\n"
73
73
  },
74
74
  {
75
75
  "path": "model.ts",
@@ -77,11 +77,11 @@
77
77
  },
78
78
  {
79
79
  "path": "module.ts",
80
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeCycleRewardWatcher } from \"./components/CycleReward\";\nimport { makeLeaderboardScreen } from \"./LeaderboardScreen\";\nimport { hasTitleData, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const leaderboardModule: Module = defineModule({\n id: \"leaderboard\",\n meta: { name: \"Leaderboards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"leaderboard\",\n label: () => t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 30,\n primary: true,\n available: false,\n Screen: makeLeaderboardScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"leaderboard\",\n hasTitleData(\n configSection<LeaderboardSection>(ctx.client, \"Leaderboard\"),\n ),\n \"no enabled leaderboard on the title\",\n );\n ctx.registerPanel({\n id: \"cycle-reward\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCycleRewardWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"leaderboard\" }),\n describeActions: {},\n });\n },\n});\n"
80
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeCycleRewardWatcher } from \"./components/CycleReward\";\nimport { makeLeaderboardScreen } from \"./LeaderboardScreen\";\nimport { hasTitleData, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const leaderboardModule: Module = defineModule({\n id: \"leaderboard\",\n meta: { name: \"Leaderboards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"leaderboard\",\n label: () => t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 30,\n primary: true,\n available: false,\n // a UScreen page: edge to edge, the whole height (the lobby gives it all of its area)\n fullBleed: true,\n Screen: makeLeaderboardScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"leaderboard\",\n hasTitleData(\n configSection<LeaderboardSection>(ctx.client, \"Leaderboard\"),\n ),\n \"no enabled leaderboard on the title\",\n );\n ctx.registerPanel({\n id: \"cycle-reward\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCycleRewardWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"leaderboard\" }),\n describeActions: {},\n });\n },\n});\n"
81
81
  },
82
82
  {
83
83
  "path": "module.meta.json",
84
- "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
84
+ "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
85
85
  }
86
86
  ]
87
87
  }
@@ -29,15 +29,15 @@
29
29
  "name": "iDos Games",
30
30
  "url": "https://idosgames.com"
31
31
  },
32
- "version": "0.1.2"
32
+ "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.22.1",
36
- "@idosgames/module-sdk": "0.12.0",
37
- "@idosgames/react": "0.5.2",
35
+ "@idosgames/core": "0.23.0",
36
+ "@idosgames/module-sdk": "0.13.0",
37
+ "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "5be13857ce50d58ef706d89709dd31ece307812b1072f876a63754c080d33cec",
40
+ "contentHash": "f2bbbcd4ac394d7b2325749a9c440987e5abe8eb754aff076ed244cc5c29a272",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  {
51
51
  "path": "LootboxesScreen.tsx",
52
- "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n backgroundColor: \"#3a2fd0\",\n backgroundImage: `repeating-linear-gradient(135deg, rgba(128,230,255,.15) 0 14px, rgba(128,230,255,0) 14px 28px), radial-gradient(ellipse at 50% 45%, #6154ff 0%, #3a2fd0 75%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(115,242,255,.45)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 10px 0 rgba(0,0,0,.3))\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background:\n \"radial-gradient(circle at 50% 35%, #ff6070 0%, #ff243d 70%)\",\n border: \"3px solid #540a10\",\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\n`;\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
52
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n KitPortal,\n ResourceList,\n UCloseCircle,\n URays,\n URibbon,\n UStyles,\n configSection,\n errorText,\n grantedBy,\n outlined,\n uText,\n useCatalog,\n useCelebrate,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} fallback=\"chest\" />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/**\n * Unity LootboxOpeningPopUp, full screen: the violet glow with the pattern, the orange CHEST ribbon,\n * the box over turning cyan rays and sparkles, the red count badge, \"Tap it now!\". A tap shakes the\n * box while the server rolls it, a white flash bursts it open, and the rewards come in the kit's\n * reward window.\n */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLButtonElement>(null);\n const [phase, setPhase] = useState<\"idle\" | \"shake\" | \"burst\">(\"idle\");\n const name = lootboxName(option.lootboxID, catalog.localize);\n\n useEffect(() => {\n play(\"open\");\n }, []);\n\n const openBox = async () => {\n if (phase !== \"idle\") return;\n setPhase(\"shake\");\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 900 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n if (!res.ok) {\n setPhase(\"idle\");\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setPhase(\"burst\");\n await new Promise((r) => setTimeout(r, kit.motion.on ? 450 : 0));\n onClose();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 40,\n overflow: \"hidden\",\n // The kit layer lets clicks through; a full-screen window takes them itself.\n pointerEvents: \"auto\",\n display: \"grid\",\n gridTemplateRows: \"auto 1fr auto\",\n justifyItems: \"center\",\n padding:\n \"max(28px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom))\",\n // The sheet's \"Tap it now!\": a blue burst — light from the middle, faint rays around it.\n backgroundColor: v.blueDeep,\n backgroundImage: `repeating-conic-gradient(from 0deg at 50% 45%, rgba(255,255,255,.07) 0 9deg, transparent 9deg 18deg), radial-gradient(ellipse at 50% 45%, color-mix(in srgb, ${v.blue} 65%, #fff) 0%, ${v.blue} 35%, ${v.blueDeep} 80%)`,\n animation: \"idos-u-pop .3s ease-out both\",\n fontFamily: \"inherit\",\n }}\n >\n <UStyles />\n <div style={{ width: \"100%\", maxWidth: 360, marginTop: 12 }}>\n <URibbon tone=\"orange\" width={340}>\n {count > 1 ? `${name.toUpperCase()} ×${count}` : name.toUpperCase()}\n </URibbon>\n </div>\n\n <div\n style={{\n position: \"relative\",\n width: 300,\n height: 300,\n alignSelf: \"center\",\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <span style={{ position: \"absolute\", inset: -40, opacity: 0.9 }}>\n <URays size={380} color=\"rgba(255,255,255,.35)\" />\n </span>\n {SPARKS.map(([x, y, s, d], i) => (\n <span\n key={i}\n style={{\n position: \"absolute\",\n left: x,\n top: y,\n animation: `idos-u-float ${d}s ease-in-out infinite`,\n opacity: 0.85,\n }}\n >\n <Icon glyph=\"star\" size={s} />\n </span>\n ))}\n <button\n type=\"button\"\n ref={boxRef}\n onClick={() => void openBox()}\n aria-label={t(\"open\")}\n style={{\n position: \"relative\",\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: phase === \"idle\" ? \"pointer\" : \"default\",\n filter: \"drop-shadow(0 10px 0 rgba(0,0,0,.3))\",\n animation:\n phase === \"shake\"\n ? \"idos-u-shake .18s linear infinite\"\n : phase === \"burst\"\n ? \"idos-loot-burst .45s ease-in forwards\"\n : \"idos-u-float 1.8s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={210} fallback=\"chest\" />\n {count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n left: 6,\n top: 6,\n minWidth: 50,\n height: 50,\n borderRadius: 25,\n background: `radial-gradient(circle at 50% 35%, color-mix(in srgb, ${v.red} 70%, #fff) 0%, ${v.red} 70%)`,\n border: `3px solid ${v.ink}`,\n display: \"grid\",\n placeItems: \"center\",\n ...uText(28),\n }}\n >\n {count}\n </span>\n ) : null}\n </button>\n </div>\n\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 14 }}>\n {option.price.length > 0 ? (\n <ResourceList\n lines={option.price.map((l) => ({\n ...l,\n amount: l.amount * count,\n }))}\n size={22}\n />\n ) : null}\n <div\n style={{\n ...uText(30),\n animation:\n phase === \"idle\"\n ? \"idos-u-attract 1s ease-in-out infinite\"\n : undefined,\n visibility: phase === \"idle\" ? \"visible\" : \"hidden\",\n }}\n >\n {t(\"tapNow\")}\n </div>\n <UCloseCircle\n onClick={() => (phase === \"idle\" ? onClose() : undefined)}\n />\n </div>\n\n {/* Flash */}\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n background: \"#fff\",\n pointerEvents: \"none\",\n opacity: 0,\n animation:\n phase === \"burst\"\n ? \"idos-loot-flash .45s ease-out both\"\n : undefined,\n }}\n />\n <style>{LOOT_CSS}</style>\n </div>\n </KitPortal>\n );\n}\n\n/** Image_Effect_Star / Square sparkles around the box: left, top, size, float period. */\nconst SPARKS: Array<[number, number, number, number]> = [\n [18, 30, 34, 2.2],\n [244, 64, 42, 2.8],\n [30, 220, 28, 2.5],\n [250, 236, 30, 3.1],\n];\n\nconst LOOT_CSS = `\n@keyframes idos-loot-flash { 0% { opacity: 0 } 25% { opacity: 1 } 100% { opacity: 0 } }\n@keyframes idos-loot-burst { 0% { transform: scale(1) } 40% { transform: scale(1.25) } 100% { transform: scale(1.6); opacity: 0 } }\n`;\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function watchLootboxes(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
64
+ "content": "{\n \"id\": \"lootboxes\",\n \"type\": \"feature\",\n \"summary\": \"Chests for any game: the title's lootboxes with every way to open them, once or ten at a time, with an opening animation.\",\n \"description\": \"A ready chests screen (Unity LootboxOpeningPopUp) for ANY game, built on the @idosgames/react/ui kit and client.lootbox. Every lootbox of the title as a card for each way to open it (currencies, a chest item, a key); 'Open' once, and 'Open ×10' where the lootbox's ceiling (MaxOpenCount) and the player's balance allow — the box shakes while the server rolls it, bursts, and the rewards fly out. The inventory hands a chest or key item over (ctx.features.open('lootboxes', { itemID })) — the screen then shows what that item opens. Registers the 'lootboxes' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"lootbox list with every way to open\",\n \"opening animation with rewards\",\n \"open ten at once\",\n \"open a chest with a key item\",\n \"badge for openable chests\"\n ],\n \"tags\": [\"lootbox\", \"chest\", \"gacha\", \"rewards\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.3\"\n}\n"
65
65
  }
66
66
  ]
67
67
  }
@@ -33,15 +33,15 @@
33
33
  "name": "iDos Games",
34
34
  "url": "https://idosgames.com"
35
35
  },
36
- "version": "0.1.1"
36
+ "version": "0.1.2"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.22.1",
40
- "@idosgames/module-sdk": "0.12.0",
41
- "@idosgames/react": "0.5.2",
39
+ "@idosgames/core": "0.23.0",
40
+ "@idosgames/module-sdk": "0.13.0",
41
+ "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "6863f9fdc8aa3520038c623143ed8b3905c8ab7ae4f4498ab999236732e9e60c",
44
+ "contentHash": "fc1055f63f1791b87f8b1ba7b07177a561f7512edc18ca1447597327c58e57ee",
45
45
  "files": [
46
46
  {
47
47
  "path": "components/FriendChip.tsx",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  {
59
59
  "path": "components/LetterRows.tsx",
60
- "content": "import { useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { type MailboxLetter, type MailboxPendingReward } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n type Glyph,\n grantedBy,\n grantLines,\n Icon,\n isGlyph,\n outlined,\n panel,\n ResourceList,\n Timer,\n useCelebrate,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { expiresSoon, letterState, letterText } from \"../model\";\nimport { builtInText, t } from \"../i18n\";\nimport { CATEGORY_GLYPH, MODULE_GLYPH, words } from \"../helpers\";\n\nfunction LetterIcon({\n icon,\n fallback,\n size = 34,\n}: {\n icon: string | null | undefined;\n fallback: Glyph;\n size?: number;\n}): ReactNode {\n const glyph =\n icon && (isGlyph(icon) || !/^https?:/i.test(icon)) ? icon : null;\n if (icon && /^https?:/i.test(icon))\n return (\n <img\n src={icon}\n alt=\"\"\n style={{\n width: size,\n height: size,\n borderRadius: 8,\n objectFit: \"cover\",\n }}\n />\n );\n return <Icon glyph={glyph ?? fallback} size={size} />;\n}\n\nexport function LetterRow({\n letter,\n now,\n onOpen,\n onClaimed,\n style,\n}: {\n letter: MailboxLetter;\n now: number;\n onOpen: () => void;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const state = letterState(letter, now);\n const text = letterText(letter, builtInText);\n const unread = !letter.ReadAt;\n const reward = grantLines(letter.Rewards);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n await onClaimed();\n };\n\n return (\n <div\n ref={ref}\n onClick={onOpen}\n className=\"idos-press\"\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n border: state === \"claimable\" ? `2px solid ${v.gold}` : panel.border,\n opacity: state === \"claimed\" || state === \"expired\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <LetterIcon\n icon={letter.Icon}\n fallback={CATEGORY_GLYPH[letter.Category]}\n />\n {unread ? (\n <span\n style={{\n position: \"absolute\",\n top: -2,\n right: -2,\n width: 10,\n height: 10,\n borderRadius: 5,\n background: v.red,\n }}\n />\n ) : null}\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n fontWeight: unread ? 800 : 600,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {text.subject || t(\"title\")}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n {letter.SenderName ? <span>{letter.SenderName}</span> : null}\n <span>{new Date(letter.SentAt).toLocaleDateString()}</span>\n {state === \"claimable\" && expiresSoon(letter, now) ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n {reward.length > 0 ? <ResourceList lines={reward} size={20} /> : null}\n </div>\n {state === \"claimable\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={(e) => {\n e.stopPropagation();\n void claim();\n }}\n >\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : state === \"expired\" ? (\n <Icon glyph=\"clock\" size={22} />\n ) : null}\n </div>\n );\n}\n\nexport function PendingRow({\n reward,\n onClaimed,\n style,\n}: {\n reward: MailboxPendingReward;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const text = letterText(reward, builtInText);\n const lines = grantLines(reward.Rewards);\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(reward.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : lines);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: `2px solid ${v.gold}`,\n ...style,\n }}\n >\n <Icon glyph={MODULE_GLYPH[reward.Module] ?? \"chest\"} size={34} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{text.subject}</div>\n {text.body ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {text.body}\n </div>\n ) : null}\n {lines.length > 0 ? <ResourceList lines={lines} size={20} /> : null}\n </div>\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n"
60
+ "content": "import { useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { type MailboxLetter, type MailboxPendingReward } from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n type Glyph,\n grantedBy,\n grantLines,\n Icon,\n IconTile,\n inked,\n isGlyph,\n listRow,\n outlined,\n ResourceList,\n Timer,\n useCelebrate,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { expiresSoon, letterState, letterText } from \"../model\";\nimport { builtInText, t } from \"../i18n\";\nimport { CATEGORY_GLYPH, MODULE_GLYPH, words } from \"../helpers\";\n\nfunction LetterIcon({\n icon,\n fallback,\n size = 34,\n}: {\n icon: string | null | undefined;\n fallback: Glyph;\n size?: number;\n}): ReactNode {\n const glyph =\n icon && (isGlyph(icon) || !/^https?:/i.test(icon)) ? icon : null;\n if (icon && /^https?:/i.test(icon))\n return (\n <img\n src={icon}\n alt=\"\"\n style={{\n width: size,\n height: size,\n borderRadius: 8,\n objectFit: \"cover\",\n }}\n />\n );\n return <Icon glyph={glyph ?? fallback} size={size} />;\n}\n\nexport function LetterRow({\n letter,\n now,\n onOpen,\n onClaimed,\n style,\n}: {\n letter: MailboxLetter;\n now: number;\n onOpen: () => void;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const state = letterState(letter, now);\n const text = letterText(letter, builtInText);\n const unread = !letter.ReadAt;\n const reward = grantLines(letter.Rewards);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n await onClaimed();\n };\n\n return (\n <div\n ref={ref}\n onClick={onOpen}\n className=\"idos-press\"\n // The sheet's Postbox row: the icon in a light tile, the sender line over the subject, Claim.\n style={{\n ...listRow,\n padding: 8,\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n cursor: \"pointer\",\n opacity: state === \"claimed\" || state === \"expired\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <IconTile size={62}>\n <LetterIcon\n icon={letter.Icon}\n fallback={CATEGORY_GLYPH[letter.Category]}\n size={42}\n />\n </IconTile>\n {unread ? (\n <span\n style={{\n position: \"absolute\",\n top: -4,\n right: -4,\n width: 14,\n height: 14,\n borderRadius: 7,\n background: v.red,\n border: `2px solid ${v.ink}`,\n }}\n />\n ) : null}\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div\n style={{\n ...inked,\n fontSize: 17,\n lineHeight: 1.15,\n overflow: \"hidden\",\n display: \"-webkit-box\",\n WebkitLineClamp: 2,\n WebkitBoxOrient: \"vertical\",\n }}\n >\n {text.subject || t(\"title\")}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n {letter.SenderName ? <span>{letter.SenderName}</span> : null}\n <span>{new Date(letter.SentAt).toLocaleDateString()}</span>\n {state === \"claimable\" && expiresSoon(letter, now) ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n {reward.length > 0 ? (\n <div style={{ display: \"flex\" }}>\n <ResourceList lines={reward} size={20} />\n </div>\n ) : null}\n </div>\n {state === \"claimable\" ? (\n <Button\n tone=\"green\"\n attract\n busy={busy}\n style={{ minWidth: 110, minHeight: 52 }}\n onClick={(e) => {\n e.stopPropagation();\n void claim();\n }}\n >\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <Icon glyph=\"check\" size={34} />\n ) : state === \"expired\" ? (\n <Icon glyph=\"clock\" size={30} />\n ) : null}\n </div>\n );\n}\n\nexport function PendingRow({\n reward,\n onClaimed,\n style,\n}: {\n reward: MailboxPendingReward;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const text = letterText(reward, builtInText);\n const lines = grantLines(reward.Rewards);\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(reward.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : lines);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...listRow,\n padding: 8,\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n ...style,\n }}\n >\n <IconTile glyph={MODULE_GLYPH[reward.Module] ?? \"chest\"} size={62} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div style={{ ...inked, fontSize: 17 }}>{text.subject}</div>\n {text.body ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {text.body}\n </div>\n ) : null}\n {lines.length > 0 ? (\n <div style={{ display: \"flex\" }}>\n <ResourceList lines={lines} size={20} />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract\n busy={busy}\n style={{ minWidth: 110, minHeight: 52 }}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n"
61
61
  },
62
62
  {
63
63
  "path": "components/SocialGifts.tsx",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  {
83
83
  "path": "MailboxScreen.tsx",
84
- "content": "import {\n type ModuleContext,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type MailboxDefinitions,\n type MailboxLetter,\n type MailboxPendingReward,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n configSection,\n ConfirmPopup,\n EmptyState,\n grantedBy,\n Icon,\n outlined,\n pickLanguage,\n Spinner,\n Tabs,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { claimableCount, letterState, mailBadge, mergePages } from \"./model\";\nimport { t } from \"./i18n\";\nimport { GiftsPopup } from \"./components/GiftsPopup\";\nimport { LetterPopup } from \"./components/LetterPopup\";\nimport { LetterRow, PendingRow } from \"./components/LetterRows\";\nimport { type MailboxArgs, type Tab, words } from \"./helpers\";\n\n// Mailbox (Unity V2/Runtime/UI/Mailbox): letters newest first by category, a letter opened in a\n// popup, \"Claim\" / \"Claim all\", missed rewards of other systems on top, and \"Send gifts\" — a free\n// daily gift to friends and a transfer of own resources. Everything the server decides (expiry,\n// limits, friendship) comes back as its codes; the screen only words them.\n\n/** How often the badge re-reads the counters while the game is on screen. */\nconst MAIL_POLL_MS = 2 * 60_000;\n\n/**\n * Badge on login, on coming back to the tab and every few minutes; mailbox actions refresh it\n * themselves.\n */\nexport function watchMailbox(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n // The badge comes from the read's own answer, not only from the event: the counters usually\n // arrive WITH the login (before this watcher subscribed), and a read the central cache answers\n // emits nothing.\n const load = () =>\n void client.mailbox\n .getUnreadCount()\n .then((r) => r.ok && features.setBadge(\"mailbox\", mailBadge(r.data)));\n load();\n const onVisible = () => {\n if (document.visibilityState === \"visible\") load();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n // Letters also come while the player stays in the game (a friend's gift, a leaderboard prize):\n // look now and then, or the badge waited for the next tab switch.\n const id = setInterval(() => {\n if (document.visibilityState === \"visible\") load();\n }, MAIL_POLL_MS);\n return () => {\n off();\n clearInterval(id);\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n });\n}\n\nexport function makeMailboxScreen(features: FeatureRegistry) {\n return function MailboxScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const defs = configSection<MailboxDefinitions>(client, \"Mailbox\");\n if (defs?.Enabled !== true)\n return <EmptyState glyph=\"gift\" text={t(\"disabled\")} />;\n return (\n <Mailbox\n features={features}\n defs={defs}\n args={(args ?? {}) as MailboxArgs}\n />\n );\n };\n}\n\nfunction Mailbox({\n features,\n defs,\n args,\n}: {\n features: FeatureRegistry;\n defs: MailboxDefinitions;\n args: MailboxArgs;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const now = useNow(30_000);\n const [tab, setTab] = useState<Tab>(\"all\");\n const [letters, setLetters] = useState<MailboxLetter[] | null>(null);\n const [pending, setPending] = useState<MailboxPendingReward[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [loadingMore, setLoadingMore] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [opened, setOpened] = useState<MailboxLetter | null>(null);\n const [askClean, setAskClean] = useState(false);\n const [gifts, setGifts] = useState<MailboxArgs | null>(\n args.friendUserID || args.mode ? args : null,\n );\n\n const category = tab === \"all\" ? undefined : tab;\n // Letters in the language the screen speaks: the game's own locale when it has localization\n // tables, else the kit's language — otherwise a Russian screen showed English letters.\n const language = client.localization.locale ?? pickLanguage();\n const load = useCallback(async () => {\n const res = await client.mailbox.getMessages({ category, language });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n setLetters([]);\n return;\n }\n setLetters(res.data.Messages);\n setPending(res.data.PendingRewards ?? []);\n setCursor(res.data.NextCursor ?? null);\n }, [client, category, language]);\n useEffect(() => {\n setLetters(null);\n void load();\n }, [load]);\n\n const more = async () => {\n if (!cursor) return;\n setLoadingMore(true);\n const res = await client.mailbox.getMessages({\n category,\n cursor,\n language,\n });\n setLoadingMore(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n setLetters((shown) => mergePages(shown ?? [], res.data.Messages));\n setCursor(res.data.NextCursor ?? null);\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.mailbox.claimAll();\n setBusyAll(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n const got = grantedBy(res.data);\n if (got.length > 0) celebrate(got);\n if (res.data.Failed.length > 0)\n toast(words(res.data.Failed[0]?.Error), \"error\");\n await load();\n };\n\n const cleanRead = async () => {\n const res = await client.mailbox.deleteMessages(\"allRead\");\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(t(\"deleted\"));\n await load();\n };\n\n const giftsOn =\n Object.values(defs.Gifts?.Social ?? {}).some(\n (g) => g && g.Enabled !== false,\n ) || defs.Gifts?.Transfer?.Enabled === true;\n const shownPending = tab === \"all\" || tab === \"Reward\" ? pending : [];\n const toClaim = letters ? claimableCount(letters, shownPending, now) : 0;\n const hasRead = (letters ?? []).some(\n (l) => l.ReadAt && letterState(l, now) !== \"claimable\",\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <Tabs<Tab>\n tabs={[\n { id: \"all\", label: t(\"tabAll\") },\n { id: \"Reward\", label: t(\"tabReward\"), glyph: \"chest\" },\n { id: \"Gift\", label: t(\"tabGift\"), glyph: \"gift\" },\n { id: \"System\", label: t(\"tabSystem\"), glyph: \"bell\" },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n {toClaim > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n style={{ flex: 1 }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"mailbox:gifts\"\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"sendGifts\")}\n </Button>\n ) : null}\n </div>\n\n {letters === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n <>\n {shownPending.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"waiting\")}\n </div>\n ) : null}\n {shownPending.map((p, i) => (\n <PendingRow\n key={p.MessageID}\n reward={p}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n {letters.length === 0 && shownPending.length === 0 ? (\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n ) : null}\n {letters.map((l, i) => (\n <LetterRow\n key={l.MessageID}\n letter={l}\n now={now}\n onOpen={() => setOpened(l)}\n onClaimed={load}\n style={stagger(i + shownPending.length)}\n />\n ))}\n {cursor ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={loadingMore}\n onClick={() => void more()}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => setAskClean(true)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </>\n )}\n\n {opened ? (\n <LetterPopup\n letter={opened}\n features={features}\n onClose={() => setOpened(null)}\n onRead={(id, readAt) =>\n setLetters((shown) =>\n (shown ?? []).map((l) =>\n l.MessageID === id ? { ...l, ReadAt: readAt } : l,\n ),\n )\n }\n onChanged={load}\n />\n ) : null}\n {askClean ? (\n <ConfirmPopup\n title={t(\"deleteRead\")}\n tone=\"red\"\n onClose={() => setAskClean(false)}\n onConfirm={cleanRead}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteReadAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {gifts ? (\n <GiftsPopup\n defs={defs}\n initial={gifts}\n onClose={() => setGifts(null)}\n />\n ) : null}\n </div>\n );\n}\n"
84
+ "content": "import {\n type ModuleContext,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport {\n type MailboxDefinitions,\n type MailboxLetter,\n type MailboxPendingReward,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Columns,\n Button,\n Center,\n configSection,\n ConfirmPopup,\n EmptyState,\n grantedBy,\n Icon,\n outlined,\n pickLanguage,\n Spinner,\n Tabs,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { claimableCount, letterState, mailBadge, mergePages } from \"./model\";\nimport { t } from \"./i18n\";\nimport { GiftsPopup } from \"./components/GiftsPopup\";\nimport { LetterPopup } from \"./components/LetterPopup\";\nimport { LetterRow, PendingRow } from \"./components/LetterRows\";\nimport { type MailboxArgs, type Tab, words } from \"./helpers\";\n\n// Mailbox (Unity V2/Runtime/UI/Mailbox): letters newest first by category, a letter opened in a\n// popup, \"Claim\" / \"Claim all\", missed rewards of other systems on top, and \"Send gifts\" — a free\n// daily gift to friends and a transfer of own resources. Everything the server decides (expiry,\n// limits, friendship) comes back as its codes; the screen only words them.\n\n/** How often the badge re-reads the counters while the game is on screen. */\nconst MAIL_POLL_MS = 2 * 60_000;\n\n/** A button under the letters: across the columns, its own size in the middle. */\nconst ACROSS = {\n gridColumn: \"1 / -1\",\n justifySelf: \"center\",\n minWidth: 200,\n} as const;\n\n/**\n * Badge on login, on coming back to the tab and every few minutes; mailbox actions refresh it\n * themselves.\n */\nexport function watchMailbox(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n // The badge comes from the read's own answer, not only from the event: the counters usually\n // arrive WITH the login (before this watcher subscribed), and a read the central cache answers\n // emits nothing.\n const load = () =>\n void client.mailbox\n .getUnreadCount()\n .then((r) => r.ok && features.setBadge(\"mailbox\", mailBadge(r.data)));\n load();\n const onVisible = () => {\n if (document.visibilityState === \"visible\") load();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n // Letters also come while the player stays in the game (a friend's gift, a leaderboard prize):\n // look now and then, or the badge waited for the next tab switch.\n const id = setInterval(() => {\n if (document.visibilityState === \"visible\") load();\n }, MAIL_POLL_MS);\n return () => {\n off();\n clearInterval(id);\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n });\n}\n\nexport function makeMailboxScreen(features: FeatureRegistry) {\n return function MailboxScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const defs = configSection<MailboxDefinitions>(client, \"Mailbox\");\n if (defs?.Enabled !== true)\n return <EmptyState glyph=\"gift\" text={t(\"disabled\")} />;\n return (\n <Mailbox\n features={features}\n defs={defs}\n args={(args ?? {}) as MailboxArgs}\n />\n );\n };\n}\n\nfunction Mailbox({\n features,\n defs,\n args,\n}: {\n features: FeatureRegistry;\n defs: MailboxDefinitions;\n args: MailboxArgs;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const now = useNow(30_000);\n const [tab, setTab] = useState<Tab>(\"all\");\n const [letters, setLetters] = useState<MailboxLetter[] | null>(null);\n const [pending, setPending] = useState<MailboxPendingReward[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [loadingMore, setLoadingMore] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [opened, setOpened] = useState<MailboxLetter | null>(null);\n const [askClean, setAskClean] = useState(false);\n const [gifts, setGifts] = useState<MailboxArgs | null>(\n args.friendUserID || args.mode ? args : null,\n );\n\n const category = tab === \"all\" ? undefined : tab;\n // Letters in the language the screen speaks: the game's own locale when it has localization\n // tables, else the kit's language — otherwise a Russian screen showed English letters.\n const language = client.localization.locale ?? pickLanguage();\n const load = useCallback(async () => {\n const res = await client.mailbox.getMessages({ category, language });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n setLetters([]);\n return;\n }\n setLetters(res.data.Messages);\n setPending(res.data.PendingRewards ?? []);\n setCursor(res.data.NextCursor ?? null);\n }, [client, category, language]);\n useEffect(() => {\n setLetters(null);\n void load();\n }, [load]);\n\n const more = async () => {\n if (!cursor) return;\n setLoadingMore(true);\n const res = await client.mailbox.getMessages({\n category,\n cursor,\n language,\n });\n setLoadingMore(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n setLetters((shown) => mergePages(shown ?? [], res.data.Messages));\n setCursor(res.data.NextCursor ?? null);\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.mailbox.claimAll();\n setBusyAll(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n const got = grantedBy(res.data);\n if (got.length > 0) celebrate(got);\n if (res.data.Failed.length > 0)\n toast(words(res.data.Failed[0]?.Error), \"error\");\n await load();\n };\n\n const cleanRead = async () => {\n const res = await client.mailbox.deleteMessages(\"allRead\");\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(t(\"deleted\"));\n await load();\n };\n\n const giftsOn =\n Object.values(defs.Gifts?.Social ?? {}).some(\n (g) => g && g.Enabled !== false,\n ) || defs.Gifts?.Transfer?.Enabled === true;\n const shownPending = tab === \"all\" || tab === \"Reward\" ? pending : [];\n const toClaim = letters ? claimableCount(letters, shownPending, now) : 0;\n const hasRead = (letters ?? []).some(\n (l) => l.ReadAt && letterState(l, now) !== \"claimable\",\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <Tabs<Tab>\n tabs={[\n { id: \"all\", label: t(\"tabAll\") },\n { id: \"Reward\", label: t(\"tabReward\"), glyph: \"chest\" },\n { id: \"Gift\", label: t(\"tabGift\"), glyph: \"gift\" },\n { id: \"System\", label: t(\"tabSystem\"), glyph: \"bell\" },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n {toClaim > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n style={{ flex: \"1 1 auto\", whiteSpace: \"nowrap\" }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: \"1 1 auto\", whiteSpace: \"nowrap\" }}\n data-tutorial-anchor=\"mailbox:gifts\"\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"sendGifts\")}\n </Button>\n ) : null}\n </div>\n\n {letters === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n // the letters: one column on a phone, side by side on a tablet or a PC\n <Columns gap={10} min={340}>\n {shownPending.length > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n gridColumn: \"1 / -1\",\n }}\n >\n {t(\"waiting\")}\n </div>\n ) : null}\n {shownPending.map((p, i) => (\n <PendingRow\n key={p.MessageID}\n reward={p}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n {letters.length === 0 && shownPending.length === 0 ? (\n <div style={{ gridColumn: \"1 / -1\" }}>\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n </div>\n ) : null}\n {letters.map((l, i) => (\n <LetterRow\n key={l.MessageID}\n letter={l}\n now={now}\n onOpen={() => setOpened(l)}\n onClaimed={load}\n style={stagger(i + shownPending.length)}\n />\n ))}\n {cursor ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={loadingMore}\n onClick={() => void more()}\n style={ACROSS}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => setAskClean(true)}\n style={ACROSS}\n >\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </Columns>\n )}\n\n {opened ? (\n <LetterPopup\n letter={opened}\n features={features}\n onClose={() => setOpened(null)}\n onRead={(id, readAt) =>\n setLetters((shown) =>\n (shown ?? []).map((l) =>\n l.MessageID === id ? { ...l, ReadAt: readAt } : l,\n ),\n )\n }\n onChanged={load}\n />\n ) : null}\n {askClean ? (\n <ConfirmPopup\n title={t(\"deleteRead\")}\n tone=\"red\"\n onClose={() => setAskClean(false)}\n onConfirm={cleanRead}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteReadAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {gifts ? (\n <GiftsPopup\n defs={defs}\n initial={gifts}\n onClose={() => setGifts(null)}\n />\n ) : null}\n </div>\n );\n}\n"
85
85
  },
86
86
  {
87
87
  "path": "model.ts",
@@ -93,7 +93,7 @@
93
93
  },
94
94
  {
95
95
  "path": "module.meta.json",
96
- "content": "{\n \"id\": \"mailbox\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.\",\n \"description\": \"A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.\",\n \"provides\": [\n \"mailbox screen\",\n \"letters with rewards and claim all\",\n \"publisher broadcasts in the inbox\",\n \"gifts to friends\",\n \"resource transfers between friends\",\n \"missed rewards of other systems\",\n \"mailbox badge\"\n ],\n \"tags\": [\n \"mailbox\",\n \"mail\",\n \"inbox\",\n \"gifts\",\n \"rewards\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
96
+ "content": "{\n \"id\": \"mailbox\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Mailbox for any game: letters with rewards, publisher broadcasts, gifts from friends, resource transfers and missed rewards of other systems — with 'Claim all'.\",\n \"description\": \"A ready mailbox screen for ANY game, built on the @idosgames/react/ui kit and client.mailbox. Tabs by category (all, rewards, gifts, system); a letter opens with its text in the player's language, the sender, a banner, the rewards and a button to the screen or page the letter links to. 'Claim' per letter and 'Claim all' in one server operation; rewards of other systems the player has not taken yet (leaderboard cycle, team event prize, invite rewards) are shown on top and claimed right here. 'Send gifts': a free daily gift to friends ('to everyone' in one tap) and a transfer of own coins or items to one friend — declined or expired transfers come back. 'Delete read' cleans up. The lobby badge counts unread letters and rewards waiting. The server owns everything: limits, friendship, expiry, what can be transferred. Opens straight on a friend with ctx.features.open('mailbox', { friendUserID, mode: 'gift' | 'transfer' }) — the friends system does that from a friend's card. Registers the 'mailbox' feature, hidden until the title turns the mailbox on. Ships title-seed.json: the mailbox on, a welcome template, a daily coin gift and coin/potion transfers.\",\n \"provides\": [\n \"mailbox screen\",\n \"letters with rewards and claim all\",\n \"publisher broadcasts in the inbox\",\n \"gifts to friends\",\n \"resource transfers between friends\",\n \"missed rewards of other systems\",\n \"mailbox badge\"\n ],\n \"tags\": [\n \"mailbox\",\n \"mail\",\n \"inbox\",\n \"gifts\",\n \"rewards\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.2\"\n}\n"
97
97
  }
98
98
  ]
99
99
  }