@idosgames/mcp 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +13 -13
  4. package/registry/index.json +474 -147
  5. package/registry/modules/ai.json +65 -0
  6. package/registry/modules/board-game.json +4 -4
  7. package/registry/modules/boosts.json +66 -0
  8. package/registry/modules/character.json +7 -8
  9. package/registry/modules/chat.json +7 -8
  10. package/registry/modules/collections.json +69 -0
  11. package/registry/modules/coop-events.json +67 -0
  12. package/registry/modules/craft.json +7 -8
  13. package/registry/modules/deals.json +7 -8
  14. package/registry/modules/events.json +10 -15
  15. package/registry/modules/friends.json +8 -8
  16. package/registry/modules/idle-rpg.json +4 -4
  17. package/registry/modules/inventory.json +16 -19
  18. package/registry/modules/invite.json +9 -9
  19. package/registry/modules/leaderboard.json +7 -8
  20. package/registry/modules/lootboxes.json +67 -0
  21. package/registry/modules/mailbox.json +71 -0
  22. package/registry/modules/marketplace.json +7 -8
  23. package/registry/modules/mini-game.json +7 -7
  24. package/registry/modules/multiplayer.json +74 -0
  25. package/registry/modules/premium.json +67 -0
  26. package/registry/modules/profile.json +11 -12
  27. package/registry/modules/pvp.json +5 -6
  28. package/registry/modules/quests.json +69 -0
  29. package/registry/modules/rewards.json +17 -23
  30. package/registry/modules/season.json +10 -15
  31. package/registry/modules/store.json +7 -8
  32. package/registry/modules/tutorials.json +7 -8
  33. package/registry/modules/voxelcraft.json +16 -12
  34. package/registry/modules/workshop.json +7 -8
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/idosgames-compose-modules.json +2 -2
  37. package/registry/skills/idosgames-getting-started.json +1 -1
  38. package/registry/skills/idosgames-module-contract.json +2 -2
  39. package/registry/skills/idosgames-project-structure.json +1 -1
  40. package/registry/skills/idosgames-ui-kit.json +2 -2
  41. package/registry/skills/mailbox-system.json +6 -0
  42. package/registry/skills/multiplayer-realtime.json +2 -2
  43. package/registry/skills/workshop-system.json +1 -1
@@ -8,21 +8,18 @@
8
8
  "catalog": {
9
9
  "type": "feature",
10
10
  "inBase": true,
11
- "summary": "Inventory screen for any game: owned items by rarity with category tabs, item cards, chests opened with an animation.",
12
- "description": "A ready inventory for ANY game, built on the @idosgames/react/ui kit. It shows what the player owns from the SDK's cached inventory (no request of its own): category tabs (equipment, consumables, other), rarity frames, counts and levels, and an item card popup with the description. Lootboxes: a Chests tab lists the title's lootboxes with every way to open them, and an item that is a chest's key gets an 'Open' button — the box shakes while the server rolls it, bursts, and the rewards fly out. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) when character is installed. Registers the 'inventory' feature; its badge counts chests the player can open with the items they hold.",
11
+ "summary": "Inventory screen for any game: owned items by rarity with category tabs and item cards.",
12
+ "description": "A ready inventory for ANY game, built on the @idosgames/react/ui kit. It shows what the player owns from the SDK's cached inventory (no request of its own): category tabs (equipment, consumables, other), rarity frames, counts and levels, and an item card popup with the description. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) and a chest or key item to the chests screen (ctx.features.open('lootboxes', { itemID })) when those systems are installed. Registers the 'inventory' feature — always shown in the lobby, even before the title has items.",
13
13
  "provides": [
14
- "inventory screen with rarity and categories",
15
- "item details popup",
16
- "lootbox list and opening animation",
17
- "open a chest with a key item",
14
+ "owned items grid by rarity",
15
+ "category tabs",
16
+ "item card popup",
18
17
  "hand-off of equipment to the heroes screen",
19
- "badge for openable chests"
18
+ "hand-off of chests and keys to the chests screen"
20
19
  ],
21
20
  "tags": [
22
21
  "inventory",
23
22
  "items",
24
- "lootbox",
25
- "chest",
26
23
  "rarity",
27
24
  "ui",
28
25
  "screen",
@@ -35,36 +32,36 @@
35
32
  "version": "0.1.0"
36
33
  },
37
34
  "dependencies": {
38
- "@idosgames/core": "0.19.0",
39
- "@idosgames/module-sdk": "0.6.0",
40
- "@idosgames/react": "0.3.0",
35
+ "@idosgames/core": "0.19.1",
36
+ "@idosgames/module-sdk": "0.8.0",
37
+ "@idosgames/react": "0.4.0",
41
38
  "react": "19.2.7"
42
39
  },
43
- "contentHash": "4fee98ecaddf2c0e9a997e974160da2ad2add6b6d48c046b602c14e48bc81822",
40
+ "contentHash": "da42d93b3d21d21109e0f35338c349042dbb01ef442701439dc1d9d4ddbff98d",
44
41
  "files": [
45
42
  {
46
43
  "path": "i18n.ts",
47
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Inventory\",\n all: \"All\",\n equipment: \"Equipment\",\n consumable: \"Consumables\",\n other: \"Other\",\n chests: \"Chests\",\n empty: \"Nothing here yet — buy something in the shop.\",\n noItems: \"Items are not configured. Add them in LiveOps → Items.\",\n rarity: \"Rarity\",\n quantity: \"Quantity\",\n level: \"Level\",\n equip: \"Equip\",\n open: \"Open\",\n openFor: \"Open for\",\n noChests: \"No chests in this game yet. Add them in LiveOps → Lootboxes.\",\n toShop: \"To the shop\",\n opening: \"Opening…\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Инвентарь\",\n all: \"Все\",\n equipment: \"Снаряжение\",\n consumable: \"Расходники\",\n other: \"Разное\",\n chests: \"Сундуки\",\n empty: \"Пока пусто — купите что-нибудь в магазине.\",\n noItems: \"Предметы не настроены. Добавьте их в LiveOps → Items.\",\n rarity: \"Редкость\",\n quantity: \"Количество\",\n level: \"Уровень\",\n equip: \"Надеть\",\n open: \"Открыть\",\n openFor: \"Открыть за\",\n noChests: \"Сундуков пока нет. Добавьте их в LiveOps → Lootboxes.\",\n toShop: \"В магазин\",\n opening: \"Открываем…\",\n});\n"
44
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Inventory\",\n all: \"All\",\n equipment: \"Equipment\",\n consumable: \"Consumables\",\n other: \"Other\",\n empty: \"Nothing here yet — buy something in the shop.\",\n noItems: \"Items are not configured. Add them in LiveOps → Items.\",\n rarity: \"Rarity\",\n quantity: \"Quantity\",\n level: \"Level\",\n equip: \"Equip\",\n open: \"Open\",\n toShop: \"To the shop\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Инвентарь\",\n all: \"Все\",\n equipment: \"Снаряжение\",\n consumable: \"Расходники\",\n other: \"Разное\",\n empty: \"Пока пусто — купите что-нибудь в магазине.\",\n noItems: \"Предметы не настроены. Добавьте их в LiveOps → Items.\",\n rarity: \"Редкость\",\n quantity: \"Количество\",\n level: \"Уровень\",\n equip: \"Надеть\",\n open: \"Открыть\",\n toShop: \"В магазин\",\n});\n"
48
45
  },
49
46
  {
50
47
  "path": "index.ts",
51
- "content": "// @idosgames/mod-inventory — Inventory screen for any game: the player's items by rarity with filters, an item card, opening lootboxes with an animation, and a hand-off of equipment to the heroes screen.\n\nexport { inventoryModule } from \"./module\";\n"
48
+ "content": "// @idosgames/mod-inventory — Inventory screen for any game: the player's items by rarity with filters, an item card, and hand-offs of equipment to the heroes screen and of chests to the chests screen.\n\nexport { inventoryModule } from \"./module\";\n"
52
49
  },
53
50
  {
54
51
  "path": "InventoryScreen.tsx",
55
- "content": "import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n Tabs,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n canPay,\n categoryOf,\n lootboxOptions,\n lootboxesOpenedBy,\n ownedItems,\n type Category,\n type InventoryView,\n type LootboxOption,\n type LootboxView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// What the player owns, from the SDK's cached inventory (every purchase and claim updates it — no\n// request of its own). Tabs by category, rarest first; a tap opens the item card. Chests: a tab of\n// the title's lootboxes plus \"Open\" on an item that is a chest's key — both open with a shaking box\n// and the reward animation (Unity LootboxOpeningPopUp). Equipment hands off to the heroes screen.\n\ntype Tab = Category | \"chests\";\n\nexport function makeInventoryScreen(features: FeatureRegistry) {\n return function InventoryScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Inventory\n features={features}\n initialTab={args?.tab === \"chests\" ? \"chests\" : \"all\"}\n />\n );\n };\n}\n\nfunction Inventory({\n features,\n initialTab,\n}: {\n features: FeatureRegistry;\n initialTab: Tab;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const [tab, setTab] = useState<Tab>(initialTab);\n const [open, setOpen] = useState<string | null>(null);\n const [opening, setOpening] = useState<LootboxOption | null>(null);\n\n // Lootbox definitions come with the title config; fetch them once if the config had none.\n const lootboxSection = configSection<{\n Definitions?: Record<string, LootboxView | null> | null;\n }>(client, \"Lootbox\");\n useEffect(() => {\n if (!lootboxSection) void client.lootbox.getDefinitions();\n }, [client, lootboxSection]);\n // Instances of unstackable items (levels, equipped) come with the full inventory read only.\n useEffect(() => {\n void client.user.getUserInventory();\n }, [client]);\n const options = useMemo(\n () => lootboxOptions(lootboxSection),\n [lootboxSection],\n );\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n const owned = ownedItems(inventory, catalog.items);\n const shown =\n tab === \"all\" || tab === \"chests\"\n ? owned\n : owned.filter((o) => categoryOf(catalog.items.get(o.itemID)) === tab);\n const tile = layout === \"desktop\" ? 100 : layout === \"tablet\" ? 90 : 76;\n\n const tabs: Array<{ id: Tab; label: string }> = [\n { id: \"all\", label: t(\"all\") },\n { id: \"equipment\", label: t(\"equipment\") },\n { id: \"consumable\", label: t(\"consumable\") },\n { id: \"other\", label: t(\"other\") },\n ...(options.length > 0\n ? [{ id: \"chests\" as const, label: t(\"chests\") }]\n : []),\n ];\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n {tab === \"chests\" ? (\n <Chests options={options} inventory={inventory} onOpen={setOpening} />\n ) : catalog.items.size === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noItems\")} />\n ) : shown.length === 0 ? (\n <EmptyState\n glyph=\"chest\"\n text={t(\"empty\")}\n action={\n features.get(\"store\") ? (\n <Button onClick={() => features.open(\"store\")}>\n {t(\"toShop\")}\n </Button>\n ) : undefined\n }\n />\n ) : (\n <div\n data-resource-counter=\"inventory\"\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${tile + 8}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n justifyItems: \"center\",\n }}\n >\n {shown.map((o, i) => (\n <div\n key={o.itemID}\n data-resource-counter={`item:${o.itemID}`}\n style={stagger(i)}\n >\n <ItemTile\n itemID={o.itemID}\n count={o.count}\n level={o.instances[0]?.level}\n size={tile}\n onClick={() => setOpen(o.itemID)}\n />\n </div>\n ))}\n </div>\n )}\n {open ? (\n <ItemCard\n itemID={open}\n owned={owned.find((o) => o.itemID === open)}\n opens={lootboxesOpenedBy(open, options)}\n canEquip={\n features.get(\"character\") !== undefined &&\n categoryOf(catalog.items.get(open)) === \"equipment\"\n }\n onEquip={(instanceID) =>\n features.open(\"character\", {\n itemID: open,\n itemInstanceID: instanceID,\n })\n }\n onOpenLootbox={(option) => {\n setOpen(null);\n setOpening(option);\n }}\n onClose={() => setOpen(null)}\n />\n ) : null}\n {opening ? (\n <LootboxOpening option={opening} onClose={() => setOpening(null)} />\n ) : null}\n </div>\n );\n}\n\nfunction Chests({\n options,\n inventory,\n onOpen,\n}: {\n options: LootboxOption[];\n inventory: InventoryView | undefined;\n onOpen: (o: LootboxOption) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\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 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 {catalog.localize(o.lootboxID)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph=\"chest\" size={64} />\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 disabled={!affordable}\n onClick={() => onOpen(o)}\n style={{ width: \"100%\" }}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n </Card>\n );\n })}\n </div>\n );\n}\n\nfunction ItemCard({\n itemID,\n owned,\n opens,\n canEquip,\n onEquip,\n onOpenLootbox,\n onClose,\n}: {\n itemID: string;\n owned: ReturnType<typeof ownedItems>[number] | undefined;\n opens: LootboxOption[];\n canEquip: boolean;\n onEquip: (instanceID: string | undefined) => void;\n onOpenLootbox: (option: LootboxOption) => void;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const def = catalog.items.get(itemID);\n const rarity = def?.Metadata?.RarityID ?? \"Common\";\n const color = catalog.rarityColor(rarity);\n const description = catalog.localize(def?.Description);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div\n className=\"idos-glow\"\n style={{\n width: 120,\n height: 120,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({ kind: \"item\", id: itemID, amount: 1 })}\n size={78}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 15 }}>\n {t(\"rarity\")}: {rarity}\n </div>\n {description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {description}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"quantity\")}: {owned?.count ?? 0}\n {owned?.instances[0] && owned.instances[0].level > 0\n ? ` · ${t(\"level\")} ${owned.instances[0].level}`\n : \"\"}\n </div>\n </div>\n {canEquip ? (\n <CloseThen onClick={() => onEquip(owned?.instances[0]?.id)} tone=\"blue\">\n {t(\"equip\")}\n </CloseThen>\n ) : null}\n {opens.map((o) => (\n <Button\n key={`${o.lootboxID}:${o.optionID}`}\n tone=\"gold\"\n attract\n onClick={() => onOpenLootbox(o)}\n >\n {t(\"openFor\")} <ResourceList lines={o.price} size={18} />\n </Button>\n ))}\n </Popup>\n );\n}\n\nfunction CloseThen({\n onClick,\n tone,\n children,\n}: {\n onClick: () => void;\n tone: \"blue\" | \"green\";\n children: ReactNode;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone={tone}\n onClick={() => {\n close();\n onClick();\n }}\n >\n {children}\n </Button>\n );\n}\n\n/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\nfunction LootboxOpening({\n option,\n onClose,\n}: {\n option: LootboxOption;\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<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const openBox = async (close: () => void) => {\n setBusy(true);\n const started = Date.now();\n const res = await client.lootbox.open(option.lootboxID, 1, option.optionID);\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 ? 700 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n setBusy(false);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n close();\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={catalog.localize(option.lootboxID)}\n onClose={onClose}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 12,\n padding: \"10px 0\",\n }}\n >\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon glyph=\"chest\" size={120} />\n </div>\n <ResourceList lines={option.price} />\n </div>\n <OpenButton busy={busy} onOpen={openBox} />\n </Popup>\n );\n}\n\nfunction OpenButton({\n busy,\n onOpen,\n}: {\n busy: boolean;\n onOpen: (close: () => void) => Promise<void>;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void onOpen(close)}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n );\n}\n"
52
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n Tabs,\n outlined,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n categoryOf,\n isChestOrKey,\n ownedItems,\n type Category,\n type InventoryView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// What the player owns, from the SDK's cached inventory (every purchase and claim updates it — no\n// request of its own). Tabs by category, rarest first; a tap opens the item card. Equipment hands off\n// to the heroes screen, a chest or a key to the chests screen (the lootboxes system).\n\nexport function makeInventoryScreen(features: FeatureRegistry) {\n return function InventoryScreen(_: FeatureScreenProps): ReactNode {\n return <Inventory features={features} />;\n };\n}\n\nfunction Inventory({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const [tab, setTab] = useState<Category>(\"all\");\n const [open, setOpen] = useState<string | null>(null);\n\n // Instances of unstackable items (levels, equipped) come with the full inventory read only.\n useEffect(() => {\n void client.user.getUserInventory();\n }, [client]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n const owned = ownedItems(inventory, catalog.items);\n const shown =\n tab === \"all\"\n ? owned\n : owned.filter((o) => categoryOf(catalog.items.get(o.itemID)) === tab);\n const tile = layout === \"desktop\" ? 100 : layout === \"tablet\" ? 90 : 76;\n\n const tabs: Array<{ id: Category; label: string }> = [\n { id: \"all\", label: t(\"all\") },\n { id: \"equipment\", label: t(\"equipment\") },\n { id: \"consumable\", label: t(\"consumable\") },\n { id: \"other\", label: t(\"other\") },\n ];\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n {catalog.items.size === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noItems\")} />\n ) : shown.length === 0 ? (\n <EmptyState\n glyph=\"chest\"\n text={t(\"empty\")}\n action={\n features.get(\"store\") ? (\n <Button onClick={() => features.open(\"store\")}>\n {t(\"toShop\")}\n </Button>\n ) : undefined\n }\n />\n ) : (\n <div\n data-resource-counter=\"inventory\"\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${tile + 8}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n justifyItems: \"center\",\n }}\n >\n {shown.map((o, i) => (\n <div\n key={o.itemID}\n data-resource-counter={`item:${o.itemID}`}\n style={stagger(i)}\n >\n <ItemTile\n itemID={o.itemID}\n count={o.count}\n level={o.instances[0]?.level}\n size={tile}\n onClick={() => setOpen(o.itemID)}\n />\n </div>\n ))}\n </div>\n )}\n {open ? (\n <ItemCard\n itemID={open}\n owned={owned.find((o) => o.itemID === open)}\n canOpenChest={\n features.get(\"lootboxes\")?.available === true &&\n isChestOrKey(catalog.items.get(open))\n }\n canEquip={\n features.get(\"character\") !== undefined &&\n categoryOf(catalog.items.get(open)) === \"equipment\"\n }\n onEquip={(instanceID) =>\n features.open(\"character\", {\n itemID: open,\n itemInstanceID: instanceID,\n })\n }\n onOpenChest={() => features.open(\"lootboxes\", { itemID: open })}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction ItemCard({\n itemID,\n owned,\n canOpenChest,\n canEquip,\n onEquip,\n onOpenChest,\n onClose,\n}: {\n itemID: string;\n owned: ReturnType<typeof ownedItems>[number] | undefined;\n canOpenChest: boolean;\n canEquip: boolean;\n onEquip: (instanceID: string | undefined) => void;\n onOpenChest: () => void;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const def = catalog.items.get(itemID);\n const rarity = def?.Metadata?.RarityID ?? \"Common\";\n const color = catalog.rarityColor(rarity);\n const description = catalog.localize(def?.Description);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div\n className=\"idos-glow\"\n style={{\n width: 120,\n height: 120,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({ kind: \"item\", id: itemID, amount: 1 })}\n size={78}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 15 }}>\n {t(\"rarity\")}: {rarity}\n </div>\n {description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {description}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"quantity\")}: {owned?.count ?? 0}\n {owned?.instances[0] && owned.instances[0].level > 0\n ? ` · ${t(\"level\")} ${owned.instances[0].level}`\n : \"\"}\n </div>\n </div>\n {canEquip ? (\n <CloseThen onClick={() => onEquip(owned?.instances[0]?.id)} tone=\"blue\">\n {t(\"equip\")}\n </CloseThen>\n ) : null}\n {canOpenChest ? (\n <CloseThen onClick={onOpenChest} tone=\"green\">\n {t(\"open\")}\n </CloseThen>\n ) : null}\n </Popup>\n );\n}\n\nfunction CloseThen({\n onClick,\n tone,\n children,\n}: {\n onClick: () => void;\n tone: \"blue\" | \"green\";\n children: ReactNode;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone={tone}\n onClick={() => {\n close();\n onClick();\n }}\n >\n {children}\n </Button>\n );\n}\n"
56
53
  },
57
54
  {
58
55
  "path": "model.ts",
59
- "content": "// Pure helpers of the inventory screen — covered by model.test.ts.\n\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\nexport interface InventoryView {\n Items?: Record<\n string,\n {\n StackableAmount?: number;\n UnstackableAmount?: number;\n TotalAmount?: number;\n } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; Level?: number | null } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface OwnedItem {\n itemID: string;\n count: number;\n /** Instance ids of an unstackable item (equipment), best level first. */\n instances: Array<{ id: string; level: number }>;\n}\n\n/**\n * What the player owns: stackable counts plus the instances of unstackable items. Sorted rarest\n * first, then by id — a stable order, so the grid does not reshuffle when a count changes.\n */\nexport function ownedItems(\n inventory: InventoryView | null | undefined,\n definitions: Map<string, ItemDefinitionView>,\n): OwnedItem[] {\n const owned = new Map<string, OwnedItem>();\n const get = (id: string): OwnedItem => {\n let o = owned.get(id);\n if (!o) owned.set(id, (o = { itemID: id, count: 0, instances: [] }));\n return o;\n };\n for (const [id, totals] of Object.entries(inventory?.Items ?? {})) {\n const total = Number(\n totals?.TotalAmount ??\n (totals?.StackableAmount ?? 0) + (totals?.UnstackableAmount ?? 0),\n );\n if (total > 0) get(id).count = total;\n }\n for (const [instanceID, instance] of Object.entries(\n inventory?.UnstackableItems ?? {},\n )) {\n if (!instance?.ItemID) continue;\n get(instance.ItemID).instances.push({\n id: instanceID,\n level: Number(instance.Level ?? 0),\n });\n }\n for (const o of owned.values()) {\n if (o.count < o.instances.length) o.count = o.instances.length;\n o.instances.sort((a, b) => b.level - a.level || a.id.localeCompare(b.id));\n }\n return [...owned.values()]\n .filter((o) => o.count > 0)\n .sort((a, b) => {\n const r =\n rarityRank(definitions.get(a.itemID)?.Metadata?.RarityID) -\n rarityRank(definitions.get(b.itemID)?.Metadata?.RarityID);\n return r !== 0 ? r : a.itemID.localeCompare(b.itemID);\n });\n}\n\nexport type Category = \"all\" | \"equipment\" | \"consumable\" | \"other\";\n\nconst EQUIPMENT = [\n \"weapon\",\n \"sword\",\n \"armor\",\n \"shield\",\n \"helmet\",\n \"equipment\",\n \"gear\",\n \"ring\",\n \"boots\",\n \"amulet\",\n];\nconst CONSUMABLE = [\n \"potion\",\n \"scroll\",\n \"consumable\",\n \"food\",\n \"booster\",\n \"key\",\n \"chest\",\n \"lootbox\",\n];\n\n/** Category of an item by its class and tags (equipment = unstackable gear). */\nexport function categoryOf(\n def: ItemDefinitionView | undefined,\n): Exclude<Category, \"all\"> {\n const words = [def?.ItemClass ?? \"\", ...(def?.Tags ?? [])].map((w) =>\n w.toLowerCase(),\n );\n if (words.some((w) => EQUIPMENT.includes(w))) return \"equipment\";\n if (words.some((w) => CONSUMABLE.includes(w))) return \"consumable\";\n if (def?.IsStackable === false) return \"equipment\";\n return \"other\";\n}\n\nexport interface LootboxView {\n LootboxID?: string;\n DisplayName?: string | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n price: ResourceLine[];\n}\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section:\n | { Definitions?: Record<string, LootboxView | null> | null }\n | null\n | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n price: costLines(option.Cost as never),\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what an option costs (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.price.every((line) => {\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >=\n line.amount\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= line.amount\n );\n return true;\n });\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n\n/** The title's item catalogs, as far as the inventory tab cares. */\nexport interface ItemCatalogsView {\n Catalogs?: Record<\n string,\n { Items?: Record<string, unknown> | null } | null\n > | null;\n}\n\n/** Whether the title has items at all — until it does, the lobby hides the Inventory tab. */\nexport function hasTitleData(section: ItemCatalogsView | undefined): boolean {\n return Object.values(section?.Catalogs ?? {}).some(\n (c) => Object.keys(c?.Items ?? {}).length > 0,\n );\n}\n"
56
+ "content": "// Pure helpers of the inventory screen — covered by model.test.ts.\n\nimport { rarityRank, type ItemDefinitionView } from \"@idosgames/react/ui\";\n\nexport interface InventoryView {\n Items?: Record<\n string,\n {\n StackableAmount?: number;\n UnstackableAmount?: number;\n TotalAmount?: number;\n } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; Level?: number | null } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface OwnedItem {\n itemID: string;\n count: number;\n /** Instance ids of an unstackable item (equipment), best level first. */\n instances: Array<{ id: string; level: number }>;\n}\n\n/**\n * What the player owns: stackable counts plus the instances of unstackable items. Sorted rarest\n * first, then by id — a stable order, so the grid does not reshuffle when a count changes.\n */\nexport function ownedItems(\n inventory: InventoryView | null | undefined,\n definitions: Map<string, ItemDefinitionView>,\n): OwnedItem[] {\n const owned = new Map<string, OwnedItem>();\n const get = (id: string): OwnedItem => {\n let o = owned.get(id);\n if (!o) owned.set(id, (o = { itemID: id, count: 0, instances: [] }));\n return o;\n };\n for (const [id, totals] of Object.entries(inventory?.Items ?? {})) {\n const total = Number(\n totals?.TotalAmount ??\n (totals?.StackableAmount ?? 0) + (totals?.UnstackableAmount ?? 0),\n );\n if (total > 0) get(id).count = total;\n }\n for (const [instanceID, instance] of Object.entries(\n inventory?.UnstackableItems ?? {},\n )) {\n if (!instance?.ItemID) continue;\n get(instance.ItemID).instances.push({\n id: instanceID,\n level: Number(instance.Level ?? 0),\n });\n }\n for (const o of owned.values()) {\n if (o.count < o.instances.length) o.count = o.instances.length;\n o.instances.sort((a, b) => b.level - a.level || a.id.localeCompare(b.id));\n }\n return [...owned.values()]\n .filter((o) => o.count > 0)\n .sort((a, b) => {\n const r =\n rarityRank(definitions.get(a.itemID)?.Metadata?.RarityID) -\n rarityRank(definitions.get(b.itemID)?.Metadata?.RarityID);\n return r !== 0 ? r : a.itemID.localeCompare(b.itemID);\n });\n}\n\nexport type Category = \"all\" | \"equipment\" | \"consumable\" | \"other\";\n\nconst EQUIPMENT = [\n \"weapon\",\n \"sword\",\n \"armor\",\n \"shield\",\n \"helmet\",\n \"equipment\",\n \"gear\",\n \"ring\",\n \"boots\",\n \"amulet\",\n];\nconst CONSUMABLE = [\n \"potion\",\n \"scroll\",\n \"consumable\",\n \"food\",\n \"booster\",\n \"key\",\n \"chest\",\n \"lootbox\",\n];\n\n/** Category of an item by its class and tags (equipment = unstackable gear). */\nexport function categoryOf(\n def: ItemDefinitionView | undefined,\n): Exclude<Category, \"all\"> {\n const words = [def?.ItemClass ?? \"\", ...(def?.Tags ?? [])].map((w) =>\n w.toLowerCase(),\n );\n if (words.some((w) => EQUIPMENT.includes(w))) return \"equipment\";\n if (words.some((w) => CONSUMABLE.includes(w))) return \"consumable\";\n if (def?.IsStackable === false) return \"equipment\";\n return \"other\";\n}\n\n/** A chest or a key: an item the chests screen (the lootboxes system) opens. */\nexport function isChestOrKey(def: ItemDefinitionView | undefined): boolean {\n return [def?.ItemClass ?? \"\", ...(def?.Tags ?? [])].some((w) =>\n [\"chest\", \"lootbox\", \"key\"].includes(w.toLowerCase()),\n );\n}\n"
60
57
  },
61
58
  {
62
59
  "path": "module.ts",
63
- "content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeInventoryScreen } from \"./InventoryScreen\";\nimport {\n hasTitleData,\n lootboxOptions,\n openableWithItems,\n type InventoryView,\n type ItemCatalogsView,\n type LootboxView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The inventory as a feature of the game. Badge: chests the player can open right now with the\n// items they hold (a chest item or a key).\nexport const inventoryModule: Module = defineModule({\n id: \"inventory\",\n meta: { name: \"Inventory\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"inventory\",\n label: t(\"title\"),\n icon: \"chest\",\n group: \"economy\",\n order: 50,\n primary: true,\n available: false,\n Screen: makeInventoryScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"inventory\",\n hasTitleData(configSection<ItemCatalogsView>(ctx.client, \"Item\")),\n \"no items on the title (the Item catalogs are empty)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"inventory\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function InventoryBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const options = lootboxOptions(\n configSection<{\n Definitions?: Record<string, LootboxView | null> | null;\n }>(client, \"Lootbox\"),\n );\n features.setBadge(\n \"inventory\",\n openableWithItems(\n options,\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n };\n update();\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
60
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeInventoryScreen } from \"./InventoryScreen\";\nimport { t } from \"./i18n\";\n\n// The inventory as a feature of the game. Always in the lobby (owner's decision 29.09.2026), even on a\n// title without items yet — the screen then says so. Chests and keys hand off to the lootboxes system.\nexport const inventoryModule: Module = defineModule({\n id: \"inventory\",\n meta: { name: \"Inventory\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"inventory\",\n label: t(\"title\"),\n icon: \"box\",\n group: \"economy\",\n order: 50,\n primary: true,\n Screen: makeInventoryScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"inventory\" }),\n describeActions: {},\n });\n },\n});\n"
64
61
  },
65
62
  {
66
63
  "path": "module.meta.json",
67
- "content": "{\n \"id\": \"inventory\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Inventory screen for any game: owned items by rarity with category tabs, item cards, chests opened with an animation.\",\n \"description\": \"A ready inventory for ANY game, built on the @idosgames/react/ui kit. It shows what the player owns from the SDK's cached inventory (no request of its own): category tabs (equipment, consumables, other), rarity frames, counts and levels, and an item card popup with the description. Lootboxes: a Chests tab lists the title's lootboxes with every way to open them, and an item that is a chest's key gets an 'Open' button — the box shakes while the server rolls it, bursts, and the rewards fly out. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) when character is installed. Registers the 'inventory' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"inventory screen with rarity and categories\",\n \"item details popup\",\n \"lootbox list and opening animation\",\n \"open a chest with a key item\",\n \"hand-off of equipment to the heroes screen\",\n \"badge for openable chests\"\n ],\n \"tags\": [\n \"inventory\",\n \"items\",\n \"lootbox\",\n \"chest\",\n \"rarity\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
64
+ "content": "{\n \"id\": \"inventory\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Inventory screen for any game: owned items by rarity with category tabs and item cards.\",\n \"description\": \"A ready inventory for ANY game, built on the @idosgames/react/ui kit. It shows what the player owns from the SDK's cached inventory (no request of its own): category tabs (equipment, consumables, other), rarity frames, counts and levels, and an item card popup with the description. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) and a chest or key item to the chests screen (ctx.features.open('lootboxes', { itemID })) when those systems are installed. Registers the 'inventory' feature — always shown in the lobby, even before the title has items.\",\n \"provides\": [\n \"owned items grid by rarity\",\n \"category tabs\",\n \"item card popup\",\n \"hand-off of equipment to the heroes screen\",\n \"hand-off of chests and keys to the chests screen\"\n ],\n \"tags\": [\"inventory\", \"items\", \"rarity\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
68
65
  }
69
66
  ]
70
67
  }
@@ -9,7 +9,7 @@
9
9
  "type": "feature",
10
10
  "inBase": true,
11
11
  "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.",
12
- "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; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.",
12
+ "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.",
13
13
  "provides": [
14
14
  "own invite code and link",
15
15
  "share or copy the invite",
@@ -32,15 +32,15 @@
32
32
  "name": "iDos Games",
33
33
  "url": "https://idosgames.com"
34
34
  },
35
- "version": "0.1.0"
35
+ "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.19.0",
39
- "@idosgames/module-sdk": "0.6.0",
40
- "@idosgames/react": "0.3.0",
38
+ "@idosgames/core": "0.19.1",
39
+ "@idosgames/module-sdk": "0.8.0",
40
+ "@idosgames/react": "0.4.0",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "d161151b1e04e282113f473531db599440adbb713d52badc0d09ce6474c88a57",
43
+ "contentHash": "f3ed6702817b2217cee7bf25accd5dbb138d6657bb7f15edcc4cc8c03a26b01f",
44
44
  "files": [
45
45
  {
46
46
  "path": "i18n.ts",
@@ -56,15 +56,15 @@
56
56
  },
57
57
  {
58
58
  "path": "module.ts",
59
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes.\nexport const inviteModule: Module = defineModule({\n id: \"invite\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n available: false,\n Screen: makeReferralScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"invite\",\n hasTitleData(\n configSection<{ IsEnabled?: boolean | null }>(ctx.client, \"Referral\"),\n ),\n \"invites are off on the title (Referral.IsEnabled)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
59
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes — on a title with invites on.\nexport const inviteModule: Module = defineModule({\n id: \"invite\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n // Always in the lobby (owner's decision 29.09.2026); with invites off the screen says so.\n const enabled = hasTitleData(\n configSection<{ IsEnabled?: boolean | null }>(ctx.client, \"Referral\"),\n );\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n Screen: makeReferralScreen(enabled),\n });\n // The badge reads the referral definitions — only where invites are on. On a title without them\n // that read was one more request in the startup burst, and the engine's per-player limit\n // answered it 429.\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
60
60
  },
61
61
  {
62
62
  "path": "ReferralScreen.tsx",
63
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n plural,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\nexport function makeReferralScreen(_features: FeatureRegistry) {\n return function ReferralScreen(_: FeatureScreenProps): 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 === false)\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 background: \"linear-gradient(180deg, #2fb36b 0%, #146b3d 100%)\",\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\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}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
63
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
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; 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.0\"\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.1\"\n}\n"
68
68
  }
69
69
  ]
70
70
  }
@@ -7,7 +7,6 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
- "inBase": true,
11
10
  "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.",
12
11
  "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.",
13
12
  "provides": [
@@ -34,15 +33,15 @@
34
33
  "name": "iDos Games",
35
34
  "url": "https://idosgames.com"
36
35
  },
37
- "version": "0.1.0"
36
+ "version": "0.1.1"
38
37
  },
39
38
  "dependencies": {
40
- "@idosgames/core": "0.19.0",
41
- "@idosgames/module-sdk": "0.6.0",
42
- "@idosgames/react": "0.3.0",
39
+ "@idosgames/core": "0.19.1",
40
+ "@idosgames/module-sdk": "0.8.0",
41
+ "@idosgames/react": "0.4.0",
43
42
  "react": "19.2.7"
44
43
  },
45
- "contentHash": "779700ab18f4bf06c4b7ab37ff23bfb609f956bc3fe8e6fd2bf642c1f2c4999a",
44
+ "contentHash": "6d86641300a3b71c007d01eb4bc7ac345eef9ca99e99dda75a40d252c50c23f3",
46
45
  "files": [
47
46
  {
48
47
  "path": "i18n.ts",
@@ -54,7 +53,7 @@
54
53
  },
55
54
  {
56
55
  "path": "LeaderboardScreen.tsx",
57
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n background: mine\n ? \"linear-gradient(180deg, #4b5ae0 0%, #2a2f86 100%)\"\n : panel.background,\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? \"rgba(0,0,0,.3)\",\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
56
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n configSection,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"blue\"),\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n ...(mine\n ? toneSurface(\"blue\")\n : { background: panel.background }),\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? v.wellSoft,\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
58
57
  },
59
58
  {
60
59
  "path": "model.ts",
@@ -66,7 +65,7 @@
66
65
  },
67
66
  {
68
67
  "path": "module.meta.json",
69
- "content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"inBase\": true,\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.0\"\n}\n"
68
+ "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.1\"\n}\n"
70
69
  }
71
70
  ]
72
71
  }
@@ -0,0 +1,67 @@
1
+ {
2
+ "id": "lootboxes",
3
+ "meta": {
4
+ "name": "Chests",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "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.",
11
+ "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.",
12
+ "provides": [
13
+ "lootbox list with every way to open",
14
+ "opening animation with rewards",
15
+ "open ten at once",
16
+ "open a chest with a key item",
17
+ "badge for openable chests"
18
+ ],
19
+ "tags": [
20
+ "lootbox",
21
+ "chest",
22
+ "gacha",
23
+ "rewards",
24
+ "ui",
25
+ "screen",
26
+ "economy"
27
+ ],
28
+ "author": {
29
+ "name": "iDos Games",
30
+ "url": "https://idosgames.com"
31
+ },
32
+ "version": "0.1.0"
33
+ },
34
+ "dependencies": {
35
+ "@idosgames/core": "0.19.1",
36
+ "@idosgames/module-sdk": "0.8.0",
37
+ "@idosgames/react": "0.4.0",
38
+ "react": "19.2.7"
39
+ },
40
+ "contentHash": "64fb8cc7fb26c68c838117f01739d5bafcc8da16d797691c651658f1911f527e",
41
+ "files": [
42
+ {
43
+ "path": "i18n.ts",
44
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chests\",\n open: \"Open\",\n openTimes: \"Open\",\n opening: \"Opening…\",\n noChests: \"No chests in this game yet. Add them in LiveOps → Lootboxes.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Сундуки\",\n open: \"Открыть\",\n openTimes: \"Открыть\",\n opening: \"Открываем…\",\n noChests: \"Сундуков пока нет. Добавьте их в LiveOps → Lootboxes.\",\n});\n"
45
+ },
46
+ {
47
+ "path": "index.ts",
48
+ "content": "// @idosgames/mod-lootboxes — Chests for any game: the title's lootboxes with every way to open them, opening once or ten times with an animation.\n\nexport { lootboxesModule } from \"./module\";\n"
49
+ },
50
+ {
51
+ "path": "LootboxesScreen.tsx",
52
+ "content": "import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n type LootboxSection,\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<LootboxSection>(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 {catalog.localize(o.lootboxID)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} />\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/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\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<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const openBox = async (close: () => void) => {\n setBusy(true);\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 ? 700 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n setBusy(false);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n close();\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 <Popup\n title={\n count > 1\n ? `${catalog.localize(option.lootboxID)} ×${count}`\n : catalog.localize(option.lootboxID)\n }\n onClose={onClose}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 12,\n padding: \"10px 0\",\n }}\n >\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={120} />\n </div>\n <ResourceList\n lines={option.price.map((l) => ({ ...l, amount: l.amount * count }))}\n />\n </div>\n <OpenButton busy={busy} onOpen={openBox} />\n </Popup>\n );\n}\n\nfunction OpenButton({\n busy,\n onOpen,\n}: {\n busy: boolean;\n onOpen: (close: () => void) => Promise<void>;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void onOpen(close)}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n );\n}\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function makeLootboxesWatcher(features: FeatureRegistry) {\n return function LootboxesBadge(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection<LootboxSection>(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return null;\n };\n}\n"
53
+ },
54
+ {
55
+ "path": "model.ts",
56
+ "content": "// Pure helpers of the chests screen — covered by model.test.ts.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface LootboxView {\n LootboxID?: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n MaxOpenCount?: number | null;\n}\n\n/** The title's Lootbox section, as far as the chests screen cares. */\nexport interface LootboxSection {\n Definitions?: Record<string, LootboxView | null> | null;\n Settings?: { MaxOpenCount?: number | null } | null;\n}\n\n/** What the player holds, as far as paying for a chest goes. */\nexport interface InventoryView {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n icon: string;\n price: ResourceLine[];\n /** Most boxes one open() may take (the server clamps a bigger count to it). */\n maxOpen: number;\n}\n\n/** The platform's ceiling when neither the lootbox nor the module sets one. */\nconst DEFAULT_MAX_OPEN = 100;\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section: LootboxSection | null | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const maxOpen = Number(\n def.MaxOpenCount ?? section?.Settings?.MaxOpenCount ?? DEFAULT_MAX_OPEN,\n );\n if (maxOpen <= 0) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n icon: def.AssetPaths?.icon ?? \"chest\",\n price: costLines(option.Cost as never),\n maxOpen,\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what `times` openings of an option cost (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n times = 1,\n): boolean {\n return option.price.every((line) => {\n const need = line.amount * times;\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >= need\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= need\n );\n return true;\n });\n}\n\n/** The bulk opening offered next to a single one: ten boxes, when the ceiling and the wallet allow. */\nexport const BULK = 10;\nexport function canOpenBulk(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.maxOpen >= BULK && canPay(option, inventory, BULK);\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n\n/** Whether the title has lootboxes — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LootboxSection | undefined): boolean {\n return lootboxOptions(section).length > 0;\n}\n"
57
+ },
58
+ {
59
+ "path": "module.ts",
60
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { LootboxesScreen, makeLootboxesWatcher } from \"./LootboxesScreen\";\nimport { hasTitleData, type LootboxSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests as a feature of the game. The inventory hands a chest or key item over here\n// (features.open(\"lootboxes\", { itemID })). Badge: chests the player can open right now with the\n// items they hold — read from the cached inventory, no request of its own.\nexport const lootboxesModule: Module = defineModule({\n id: \"lootboxes\",\n meta: { name: \"Chests\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"lootboxes\",\n label: t(\"title\"),\n icon: \"chest\",\n group: \"economy\",\n order: 55,\n available: false,\n Screen: LootboxesScreen,\n });\n const enabled = hasTitleData(\n configSection<LootboxSection>(ctx.client, \"Lootbox\"),\n );\n ctx.features.setAvailable(\n \"lootboxes\",\n enabled,\n \"no lootboxes on the title (Lootbox)\",\n );\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeLootboxesWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"lootboxes\" }),\n describeActions: {},\n });\n },\n});\n"
61
+ },
62
+ {
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.0\"\n}\n"
65
+ }
66
+ ]
67
+ }