@idosgames/mcp 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +74 -22
- package/registry/index.json +761 -115
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/character.json +75 -0
- package/registry/modules/chat.json +71 -0
- package/registry/modules/craft.json +71 -0
- package/registry/modules/deals.json +70 -0
- package/registry/modules/events.json +77 -0
- package/registry/modules/friends.json +70 -0
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +70 -0
- package/registry/modules/invite.json +70 -0
- package/registry/modules/leaderboard.json +72 -0
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/profile.json +71 -0
- package/registry/modules/pvp.json +69 -0
- package/registry/modules/rewards.json +72 -0
- package/registry/modules/season.json +76 -0
- package/registry/modules/store.json +72 -0
- package/registry/modules/tutorials.json +68 -0
- package/registry/modules/voxelcraft.json +65 -464
- package/registry/modules/workshop.json +13 -8
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/game-hud.json +0 -99
|
@@ -49,21 +49,21 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@idosgames/core": "0.
|
|
53
|
-
"@idosgames/module-sdk": "0.
|
|
54
|
-
"@idosgames/react": "0.
|
|
55
|
-
"@idosgames/wallet": "0.5.
|
|
52
|
+
"@idosgames/core": "0.19.0",
|
|
53
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
54
|
+
"@idosgames/react": "0.3.0",
|
|
55
|
+
"@idosgames/wallet": "0.5.2",
|
|
56
56
|
"@tanstack/react-query": "5.101.2",
|
|
57
57
|
"phaser": "4.2.1",
|
|
58
58
|
"react": "19.2.7",
|
|
59
59
|
"viem": "2.55.2",
|
|
60
60
|
"wagmi": "3.7.2"
|
|
61
61
|
},
|
|
62
|
-
"contentHash": "
|
|
62
|
+
"contentHash": "41b86a0971beeab985e44ed77e481ce38c29df0c2fe01e34fd50159d64646e97",
|
|
63
63
|
"files": [
|
|
64
64
|
{
|
|
65
65
|
"path": "chrome.ts",
|
|
66
|
-
"content": "// Which shared chrome the Idle RPG dock draws itself.
|
|
66
|
+
"content": "// Which shared chrome the Idle RPG dock draws itself. Next to the base (its lobby draws\n// the balances and the wallet) the dock hides its currency bar and wallet — `setup()` asks\n// `ctx.sharedUi.shouldDraw(role)` once and closes over the answer. The roster, character detail and\n// equipment are this module's own UI and always stay.\n\nexport interface IdleChrome {\n /** The wallet panel in the detail column (role \"wallet\"). */\n wallet: boolean;\n /** The currency bar at the top of the dock (role \"currency-bar\"). */\n balances: boolean;\n /** The status line at the bottom of the dock (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — Idle RPG installed alone. */\nexport const FULL_CHROME: IdleChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "components/CharacterDetail.tsx",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"path": "env.ts",
|
|
102
|
-
"content": "//
|
|
102
|
+
"content": "// Build-time config the module reads the standard Vite way: VITE_* variables of the project's\n// .env.local via `import.meta.env`. Unset (as in the platform's editor), the default applies. Read\n// through a cast: a module is copied into projects that may not carry Vite's client types.\n//\n// The TITLE is deliberately NOT here. It comes from the host — `client.titleID`, or `ctx.titleId`\n// in a module's setup(). A module that re-derived the title from the URL could disagree with its\n// host, which is how a build ends up reading one title's data and writing another's.\n\nconst env: Record<string, string | undefined> =\n (import.meta as unknown as { env?: Record<string, string | undefined> })\n .env ?? {};\n\nexport const ENV_WALLETCONNECT_PROJECT_ID: string =\n env.VITE_WALLETCONNECT_PROJECT_ID ?? \"\";\n"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"path": "events.ts",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"path": "module.ts",
|
|
118
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\nimport type { IdleChrome } from \"./chrome\";\nimport { characterUpgraded } from \"./events\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n\n // Shared chrome: draw our own copy only while no other module (
|
|
118
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\nimport type { IdleChrome } from \"./chrome\";\nimport { characterUpgraded } from \"./events\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n\n // Shared chrome: draw our own copy only while no other module (the base: the lobby draws them) took the role.\n const chrome: IdleChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box, {\n chrome,\n // A signal for other modules (declared in module.meta.json → events.emits). The panel gets a\n // plain callback, so UI code never touches the bus directly.\n onCharacterUpgraded: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n }),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"⚔️\" });\n\n // Тонкая поверхность для агента — по той же причине, что и в board-game: интерфейс модуля\n // живёт в DOM, агент читает и нажимает его сам. Из Phaser'а ему не видно только то, что\n // рисует сцена.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
"path": "phaser/IdleRpgScene.ts",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "inventory",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Inventory",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
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.",
|
|
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",
|
|
18
|
+
"hand-off of equipment to the heroes screen",
|
|
19
|
+
"badge for openable chests"
|
|
20
|
+
],
|
|
21
|
+
"tags": [
|
|
22
|
+
"inventory",
|
|
23
|
+
"items",
|
|
24
|
+
"lootbox",
|
|
25
|
+
"chest",
|
|
26
|
+
"rarity",
|
|
27
|
+
"ui",
|
|
28
|
+
"screen",
|
|
29
|
+
"economy"
|
|
30
|
+
],
|
|
31
|
+
"author": {
|
|
32
|
+
"name": "iDos Games",
|
|
33
|
+
"url": "https://idosgames.com"
|
|
34
|
+
},
|
|
35
|
+
"version": "0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@idosgames/core": "0.19.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
40
|
+
"@idosgames/react": "0.3.0",
|
|
41
|
+
"react": "19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"contentHash": "4fee98ecaddf2c0e9a997e974160da2ad2add6b6d48c046b602c14e48bc81822",
|
|
44
|
+
"files": [
|
|
45
|
+
{
|
|
46
|
+
"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"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"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"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"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"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"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"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"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"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"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"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "invite",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Invite",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
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.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"own invite code and link",
|
|
15
|
+
"share or copy the invite",
|
|
16
|
+
"friends who came by your code",
|
|
17
|
+
"invite reward ladder with claim all",
|
|
18
|
+
"enter a friend's code popup",
|
|
19
|
+
"invite badge",
|
|
20
|
+
"demo referral rewards"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"referral",
|
|
24
|
+
"invite",
|
|
25
|
+
"friends",
|
|
26
|
+
"growth",
|
|
27
|
+
"social",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen"
|
|
30
|
+
],
|
|
31
|
+
"author": {
|
|
32
|
+
"name": "iDos Games",
|
|
33
|
+
"url": "https://idosgames.com"
|
|
34
|
+
},
|
|
35
|
+
"version": "0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@idosgames/core": "0.19.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
40
|
+
"@idosgames/react": "0.3.0",
|
|
41
|
+
"react": "19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"contentHash": "d161151b1e04e282113f473531db599440adbb713d52badc0d09ce6474c88a57",
|
|
44
|
+
"files": [
|
|
45
|
+
{
|
|
46
|
+
"path": "i18n.ts",
|
|
47
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Invite\",\n disabled: \"Invites are turned off in this game.\",\n yourCode: \"Your invite code\",\n copyCode: \"Copy code\",\n share: \"Share link\",\n copied: \"Copied\",\n joined: \"Friends who came by your code\",\n rewards: \"Rewards for friends\",\n friends: \"friend|friends\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n enterCode: \"Enter a friend's code\",\n enterHint: \"Got a code from a friend? Enter it and you both get a gift.\",\n youGet: \"You get\",\n activate: \"Activate\",\n badCode: \"A code is 8 letters, like WDJB-MJHT\",\n activated: \"Code activated!\",\n youCameBy: \"You joined by a friend's code\",\n inviteText: \"Join me in the game! My code:\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Пригласить\",\n disabled: \"Приглашения в этой игре выключены.\",\n yourCode: \"Ваш код приглашения\",\n copyCode: \"Скопировать код\",\n share: \"Поделиться ссылкой\",\n copied: \"Скопировано\",\n joined: \"Пришли по вашему коду\",\n rewards: \"Награды за друзей\",\n friends: \"друг|друга|друзей\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n enterCode: \"Ввести код друга\",\n enterHint: \"Друг дал вам код? Введите его — подарок получите вы оба.\",\n youGet: \"Вы получите\",\n activate: \"Активировать\",\n badCode: \"Код — это 8 букв, например WDJB-MJHT\",\n activated: \"Код активирован!\",\n youCameBy: \"Вы пришли по коду друга\",\n inviteText: \"Заходи в игру! Мой код:\",\n});\n"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "index.ts",
|
|
51
|
+
"content": "// @idosgames/mod-invite — Invite friends for any game: your code and link, entering a friend's code, and rewards for friends who joined.\n\nexport { inviteModule } from \"./module\";\n"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "model.ts",
|
|
55
|
+
"content": "// Pure helpers of the invite screen (Unity V2/Runtime/UI/Referral): the code a player types, the\n// ladder of rewards for friends who came by your code, and the badge. The server validates codes,\n// counts friends and pays — this only decides what the screen shows. Covered by model.test.ts.\n\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n/** The code alphabet: no vowels, no 0/O, no 1/I/L — so a code read aloud is typed right. */\nconst CODE_ALPHABET = /^[BCDFGHJKMNPQRSTVWXZ]{8}$/;\n\n/** A code as the player typed it: separators dropped, upper case; `null` when it can't be a code. */\nexport function normalizeCode(input: string): string | null {\n const code = input.replace(/[\\s\\-_.]/g, \"\").toUpperCase();\n return CODE_ALPHABET.test(code) ? code : null;\n}\n\n/** \"WDJB-MJHT\" — easier to read out and copy by eye. */\nexport function prettyCode(code: string | null | undefined): string {\n if (!code) return \"\";\n return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n}\n\nexport interface InviteRow {\n id: string;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function inviteRows(\n rewards:\n | Record<\n string,\n {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: unknown;\n } | null\n >\n | null\n | undefined,\n state:\n | {\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n }\n | null\n | undefined,\n): InviteRow[] {\n const friends = Number(state?.FollowersCount ?? 0);\n return Object.entries(rewards ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const id = m!.MilestoneID || key;\n const required = Number(m!.RequiredProgress ?? 0);\n const claimed = state?.InviteRewardStates?.[id]?.IsClaimed === true;\n return {\n id,\n name: m!.DisplayName ?? null,\n required,\n reward: grantLines(m!.Rewards as never),\n state: claimed ? \"claimed\" : friends >= required ? \"ready\" : \"locked\",\n } satisfies InviteRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** Badge: invite rewards ready to take. */\nexport function referralBadge(rows: InviteRow[]): number {\n return rows.filter((r) => r.state === \"ready\").length;\n}\n\n/** Whether invites are on for the title (off by default) — until they are, the lobby hides the tab. */\nexport function hasTitleData(\n section: { IsEnabled?: boolean | null } | null | undefined,\n): boolean {\n return section?.IsEnabled === true;\n}\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "module.ts",
|
|
59
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { 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"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
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"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
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"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "leaderboard",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Leaderboards",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"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
|
+
"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
|
+
"provides": [
|
|
14
|
+
"leaderboard screen with several boards as tabs",
|
|
15
|
+
"top players and my rank",
|
|
16
|
+
"round countdown",
|
|
17
|
+
"rank rewards popup",
|
|
18
|
+
"score milestones with claiming",
|
|
19
|
+
"auto popup for the reward of the finished round",
|
|
20
|
+
"badge for waiting rewards",
|
|
21
|
+
"demo leaderboard settings"
|
|
22
|
+
],
|
|
23
|
+
"tags": [
|
|
24
|
+
"leaderboard",
|
|
25
|
+
"ranking",
|
|
26
|
+
"top",
|
|
27
|
+
"milestones",
|
|
28
|
+
"rank-rewards",
|
|
29
|
+
"ui",
|
|
30
|
+
"screen",
|
|
31
|
+
"events"
|
|
32
|
+
],
|
|
33
|
+
"author": {
|
|
34
|
+
"name": "iDos Games",
|
|
35
|
+
"url": "https://idosgames.com"
|
|
36
|
+
},
|
|
37
|
+
"version": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@idosgames/core": "0.19.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
42
|
+
"@idosgames/react": "0.3.0",
|
|
43
|
+
"react": "19.2.7"
|
|
44
|
+
},
|
|
45
|
+
"contentHash": "779700ab18f4bf06c4b7ab37ff23bfb609f956bc3fe8e6fd2bf642c1f2c4999a",
|
|
46
|
+
"files": [
|
|
47
|
+
{
|
|
48
|
+
"path": "i18n.ts",
|
|
49
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Leaders\",\n top: \"Top players\",\n empty: \"Be the first on the leaderboard!\",\n noBoards: \"No leaderboard yet. Add one in LiveOps → Leaderboards.\",\n you: \"You\",\n player: \"Player\",\n myScore: \"My score\",\n myRank: \"My rank\",\n endsIn: \"Ends in\",\n rankRewards: \"Rank rewards\",\n milestones: \"Milestones\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n lastCycle: \"Last round is over!\",\n yourPlace: \"Your place\",\n reward: \"Your reward\",\n notRanked: \"not ranked\",\n play: \"Play\",\n invite: \"Invite friends\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Лидеры\",\n top: \"Лучшие игроки\",\n empty: \"Станьте первым в таблице!\",\n noBoards: \"Лидерборда пока нет. Добавьте его в LiveOps → Leaderboards.\",\n you: \"Вы\",\n player: \"Игрок\",\n myScore: \"Мои очки\",\n myRank: \"Моё место\",\n endsIn: \"До конца\",\n rankRewards: \"Награды за место\",\n milestones: \"Вехи\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n lastCycle: \"Прошлый раунд завершён!\",\n yourPlace: \"Ваше место\",\n reward: \"Ваша награда\",\n notRanked: \"вне таблицы\",\n play: \"Играть\",\n invite: \"Пригласить друзей\",\n});\n"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "index.ts",
|
|
53
|
+
"content": "// @idosgames/mod-leaderboard — Leaderboards screen for any game: several boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the previous cycle.\n\nexport { leaderboardModule } from \"./module\";\n"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"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"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "model.ts",
|
|
61
|
+
"content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Score milestones of the current cycle, in order, with what is claimable. */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : progress >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n\n/** Whether the title has an enabled leaderboard — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LeaderboardSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(\n (d) =>\n d != null && (d as { IsEnabled?: boolean | null }).IsEnabled !== false,\n );\n}\n"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"path": "module.ts",
|
|
65
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n makeCycleRewardWatcher,\n makeLeaderboardScreen,\n} from \"./LeaderboardScreen\";\nimport { hasTitleData, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const leaderboardModule: Module = defineModule({\n id: \"leaderboard\",\n meta: { name: \"Leaderboards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"leaderboard\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 30,\n primary: true,\n available: false,\n Screen: makeLeaderboardScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"leaderboard\",\n hasTitleData(\n configSection<LeaderboardSection>(ctx.client, \"Leaderboard\"),\n ),\n \"no enabled leaderboard on the title\",\n );\n ctx.registerPanel({\n id: \"cycle-reward\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCycleRewardWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"leaderboard\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"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"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|