@idosgames/mcp 0.1.17 → 0.1.18
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 +1 -1
- package/package.json +1 -1
- package/registry/host.json +14 -6
- package/registry/index.json +731 -33
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/character-ui.json +78 -0
- package/registry/modules/chat-ui.json +74 -0
- package/registry/modules/craft-ui.json +74 -0
- package/registry/modules/deal-offer-ui.json +73 -0
- package/registry/modules/events-ui.json +80 -0
- package/registry/modules/friends-ui.json +73 -0
- package/registry/modules/game-hud.json +15 -10
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory-ui.json +73 -0
- package/registry/modules/leaderboard-ui.json +75 -0
- package/registry/modules/marketplace-ui.json +75 -0
- package/registry/modules/profile-ui.json +74 -0
- package/registry/modules/pvp-ui.json +72 -0
- package/registry/modules/referral-ui.json +73 -0
- package/registry/modules/rewards-ui.json +75 -0
- package/registry/modules/season-ui.json +79 -0
- package/registry/modules/showcase.json +92 -0
- package/registry/modules/store-ui.json +75 -0
- package/registry/modules/tutorial-ui.json +71 -0
- package/registry/modules/voxelcraft.json +75 -30
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/idosgames-module-contract.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/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "inventory-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Inventory",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Inventory screen for any game: owned items by rarity with category tabs, item cards, chests opened with an animation.",
|
|
11
|
+
"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-ui is installed. Registers the 'inventory' feature; its badge counts chests the player can open with the items they hold.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"inventory screen with rarity and categories",
|
|
14
|
+
"item details popup",
|
|
15
|
+
"lootbox list and opening animation",
|
|
16
|
+
"open a chest with a key item",
|
|
17
|
+
"hand-off of equipment to the heroes screen",
|
|
18
|
+
"badge for openable chests"
|
|
19
|
+
],
|
|
20
|
+
"tags": [
|
|
21
|
+
"inventory",
|
|
22
|
+
"items",
|
|
23
|
+
"lootbox",
|
|
24
|
+
"chest",
|
|
25
|
+
"rarity",
|
|
26
|
+
"ui",
|
|
27
|
+
"screen",
|
|
28
|
+
"economy"
|
|
29
|
+
],
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "iDos Games",
|
|
32
|
+
"url": "https://idosgames.com"
|
|
33
|
+
},
|
|
34
|
+
"version": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@idosgames/core": "0.19.0",
|
|
38
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
39
|
+
"@idosgames/react": "0.3.0",
|
|
40
|
+
"react": "19.2.7"
|
|
41
|
+
},
|
|
42
|
+
"contentHash": "da8d010169e57e032afbdd0805d206b7f0cd39e0662f00a18b4bb2f7d7da7c63",
|
|
43
|
+
"files": [
|
|
44
|
+
{
|
|
45
|
+
"path": "i18n.ts",
|
|
46
|
+
"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"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "index.ts",
|
|
50
|
+
"content": "// @idosgames/mod-inventory-ui — 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 { inventoryUiModule } from \"./module\";\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "InventoryScreen.tsx",
|
|
54
|
+
"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"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"path": "model.test.ts",
|
|
58
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport { itemDefinitions } from \"@idosgames/react/ui\";\nimport {\n canPay,\n categoryOf,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n ownedItems,\n} from \"./model\";\n\nconst items = itemDefinitions({\n Catalogs: {\n Item: {\n Items: {\n potion: {\n ItemID: \"potion\",\n Tags: [\"potion\"],\n IsStackable: true,\n Metadata: { RarityID: \"Common\" },\n },\n sword: {\n ItemID: \"sword\",\n ItemClass: \"Weapon\",\n IsStackable: false,\n Metadata: { RarityID: \"Epic\" },\n },\n crown: {\n ItemID: \"crown\",\n IsStackable: false,\n Metadata: { RarityID: \"Legendary\" },\n },\n key: { ItemID: \"key\", Tags: [\"key\"], IsStackable: true },\n },\n },\n },\n});\n\ndescribe(\"inventory\", () => {\n it(\"lists owned items rarest first, with instances best level first\", () => {\n const owned = ownedItems(\n {\n Items: { potion: { StackableAmount: 3, TotalAmount: 3 } },\n UnstackableItems: {\n a: { ItemID: \"crown\" },\n b: { ItemID: \"sword\", Level: 1 },\n c: { ItemID: \"sword\", Level: 4 },\n },\n },\n items,\n );\n expect(owned.map((o) => [o.itemID, o.count])).toEqual([\n [\"crown\", 1],\n [\"sword\", 2],\n [\"potion\", 3],\n ]);\n expect(owned[1]?.instances.map((i) => i.id)).toEqual([\"c\", \"b\"]);\n expect(ownedItems(undefined, items)).toEqual([]);\n });\n\n it(\"sorts items into categories by class and tags\", () => {\n expect(categoryOf(items.get(\"sword\"))).toBe(\"equipment\");\n expect(categoryOf(items.get(\"crown\"))).toBe(\"equipment\");\n expect(categoryOf(items.get(\"potion\"))).toBe(\"consumable\");\n expect(categoryOf(undefined)).toBe(\"other\");\n });\n});\n\ndescribe(\"chests\", () => {\n const section = {\n Definitions: {\n gold_chest: {\n LootboxID: \"gold_chest\",\n PriceOptions: {\n coins: {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 300 },\n ],\n },\n },\n },\n key: {\n Cost: {\n Standard: {\n Entries: [{ Type: \"Item\", ItemID: \"key\", Amount: 1 }],\n },\n },\n },\n },\n },\n },\n };\n\n it(\"one entry per way to open, and a key item opens its chest\", () => {\n const options = lootboxOptions(section);\n expect(options.map((o) => o.optionID)).toEqual([\"coins\", \"key\"]);\n expect(lootboxesOpenedBy(\"key\", options).map((o) => o.optionID)).toEqual([\n \"key\",\n ]);\n expect(lootboxesOpenedBy(\"potion\", options)).toEqual([]);\n });\n\n it(\"checks what the player holds; the badge counts chests openable with items\", () => {\n const options = lootboxOptions(section);\n const inv = {\n VirtualCurrencies: { CO: { Amount: 100 } },\n Items: { key: { TotalAmount: 1 } },\n };\n expect(canPay(options[0]!, inv)).toBe(false);\n expect(canPay(options[1]!, inv)).toBe(true);\n expect(openableWithItems(options, inv)).toBe(1);\n expect(openableWithItems(options, { Items: {} })).toBe(0);\n });\n});\n"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "model.ts",
|
|
62
|
+
"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"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "module.ts",
|
|
66
|
+
"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 lootboxOptions,\n openableWithItems,\n type InventoryView,\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 inventoryUiModule: Module = defineModule({\n id: \"inventory-ui\",\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 Screen: makeInventoryScreen(ctx.features),\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"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"path": "module.meta.json",
|
|
70
|
+
"content": "{\n \"id\": \"inventory-ui\",\n \"type\": \"feature\",\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-ui 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"
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "leaderboard-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Leaderboards",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
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.",
|
|
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.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"leaderboard screen with several boards as tabs",
|
|
14
|
+
"top players and my rank",
|
|
15
|
+
"round countdown",
|
|
16
|
+
"rank rewards popup",
|
|
17
|
+
"score milestones with claiming",
|
|
18
|
+
"auto popup for the reward of the finished round",
|
|
19
|
+
"badge for waiting rewards",
|
|
20
|
+
"demo leaderboard settings"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"leaderboard",
|
|
24
|
+
"ranking",
|
|
25
|
+
"top",
|
|
26
|
+
"milestones",
|
|
27
|
+
"rank-rewards",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen",
|
|
30
|
+
"events"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "iDos Games",
|
|
34
|
+
"url": "https://idosgames.com"
|
|
35
|
+
},
|
|
36
|
+
"version": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@idosgames/core": "0.19.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
41
|
+
"@idosgames/react": "0.3.0",
|
|
42
|
+
"react": "19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"contentHash": "8a49ee014e841fdc2e342f03b66fbd4172e2e09a07618cd72214b7ff8f0ecb57",
|
|
45
|
+
"files": [
|
|
46
|
+
{
|
|
47
|
+
"path": "i18n.ts",
|
|
48
|
+
"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"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "index.ts",
|
|
52
|
+
"content": "// @idosgames/mod-leaderboard-ui — 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 { leaderboardUiModule } from \"./module\";\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "LeaderboardScreen.tsx",
|
|
56
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n 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"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "model.test.ts",
|
|
60
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n boardAction,\n cachedProgress,\n claimableBoards,\n enabledBoards,\n milestoneRows,\n parseRankRange,\n rewardForRank,\n type LeaderboardSection,\n} from \"./model\";\n\ndescribe(\"leaderboards\", () => {\n const section = seed.Leaderboard as unknown as LeaderboardSection;\n\n it(\"the seeded weekly board comes first; disabled boards are skipped\", () => {\n expect(enabledBoards(section)[0]?.LeaderboardID).toBe(\"weekly_score\");\n expect(\n enabledBoards({ Definitions: { b: { IsEnabled: false }, a: {} } }).map(\n (b) => b.LeaderboardID,\n ),\n ).toEqual([\"a\"]);\n expect(enabledBoards(undefined)).toEqual([]);\n });\n\n it(\"the seeded invites board asks to invite, the score board to play\", () => {\n const boards = enabledBoards(section);\n expect(boards.map((b) => b.LeaderboardID)).toEqual([\n \"weekly_score\",\n \"weekly_invites\",\n ]);\n expect(boardAction(boards[0])).toBe(\"play\");\n expect(boardAction(boards[1])).toBe(\"invite\");\n expect(\n boardAction({\n ScoreSources: [\n { SourceType: \"ReferralInvite\" },\n { SourceType: \"CustomAction\" },\n ],\n }),\n ).toBe(\"play\");\n });\n\n it(\"reads rank ranges\", () => {\n expect(parseRankRange(\"1\")).toEqual([1, 1]);\n expect(parseRankRange(\"2-5\")).toEqual([2, 5]);\n expect(parseRankRange(\"51+\")).toEqual([51, Number.POSITIVE_INFINITY]);\n expect(parseRankRange(\"top\")).toBeNull();\n });\n\n it(\"finds the reward of a rank in the seeded rank rewards\", () => {\n const board = enabledBoards(section)[0];\n expect(rewardForRank(board, 1).length).toBeGreaterThan(0);\n expect(rewardForRank(board, 0)).toEqual([]);\n expect(rewardForRank(board, 1_000_000)).toEqual([]);\n });\n\n it(\"milestones in order, with what is claimable\", () => {\n const def = {\n Milestones: {\n m2: { RequiredProgress: 500 },\n m1: { RequiredProgress: 100 },\n },\n };\n expect(milestoneRows(def, 150, []).map((m) => [m.id, m.state])).toEqual([\n [\"m1\", \"ready\"],\n [\"m2\", \"locked\"],\n ]);\n expect(milestoneRows(def, 600, [\"m1\"])[0]?.state).toBe(\"claimed\");\n });\n\n it(\"finds a board's claimed milestones under the cache's cycle-doc key\", () => {\n const state = {\n ProgressByCycle: { T123_weekly_score: { ClaimedMilestoneIDs: [\"m1\"] } },\n };\n expect(cachedProgress(state, \"weekly_score\").claimed).toEqual([\"m1\"]);\n expect(cachedProgress(state, \"other\").claimed).toEqual([]);\n });\n\n it(\"badge counts boards with something to claim\", () => {\n expect(\n claimableBoards([\n { LeaderboardID: \"a\", HasUnclaimedReward: true },\n { LeaderboardID: \"b\" },\n null,\n ]),\n ).toBe(1);\n });\n});\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "model.ts",
|
|
64
|
+
"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"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "module.ts",
|
|
68
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n makeCycleRewardWatcher,\n makeLeaderboardScreen,\n} from \"./LeaderboardScreen\";\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 leaderboardUiModule: Module = defineModule({\n id: \"leaderboard-ui\",\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 Screen: makeLeaderboardScreen(ctx.features),\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"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "module.meta.json",
|
|
72
|
+
"content": "{\n \"id\": \"leaderboard-ui\",\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.0\"\n}\n"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "marketplace-ui",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Marketplace",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.",
|
|
11
|
+
"description": "A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"player marketplace screen",
|
|
14
|
+
"listings and auctions",
|
|
15
|
+
"sell popup with price and duration",
|
|
16
|
+
"bids with the minimum next bid",
|
|
17
|
+
"direct trades",
|
|
18
|
+
"claims and history",
|
|
19
|
+
"marketplace badge",
|
|
20
|
+
"demo marketplace rules"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"marketplace",
|
|
24
|
+
"trading",
|
|
25
|
+
"auction",
|
|
26
|
+
"listing",
|
|
27
|
+
"p2p",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen",
|
|
30
|
+
"economy"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "iDos Games",
|
|
34
|
+
"url": "https://idosgames.com"
|
|
35
|
+
},
|
|
36
|
+
"version": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@idosgames/core": "0.19.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
41
|
+
"@idosgames/react": "0.3.0",
|
|
42
|
+
"react": "19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"contentHash": "991caf4844f3f3e56e846769c94999c10056178016c2c3c74ca0dd6109be6e7d",
|
|
45
|
+
"files": [
|
|
46
|
+
{
|
|
47
|
+
"path": "i18n.ts",
|
|
48
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Market\",\n market: \"Market\",\n myLots: \"My lots\",\n myBids: \"My bids\",\n trades: \"Trades\",\n claims: \"To claim\",\n history: \"History\",\n sell: \"Sell\",\n lots: \"lot|lots\",\n auctions: \"auction|auctions\",\n buy: \"Buy\",\n bid: \"Bid\",\n yourBid: \"Your bid\",\n currentBid: \"Current bid\",\n startingBid: \"Starting bid\",\n leading: \"You lead\",\n endsIn: \"Ends in\",\n listing: \"Fixed price\",\n auction: \"Auction\",\n price: \"Price\",\n amount: \"Amount\",\n duration: \"Duration\",\n hours: \"h\",\n commission: \"Commission\",\n create: \"Put up for sale\",\n cancel: \"Take back\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n accept: \"Accept\",\n decline: \"Decline\",\n gift: \"Gift\",\n wants: \"Wants\",\n from: \"From\",\n pickItem: \"Pick an item\",\n nothingToSell: \"Nothing to sell — tradable items you own appear here.\",\n noTradableCurrency:\n \"No currency of this game can be traded between players yet.\",\n empty: \"Nothing here yet.\",\n closed: \"The market is closed right now.\",\n disabled: \"No marketplace in this game. Enable it in LiveOps → Marketplace.\",\n sold: \"Sold\",\n bought: \"Bought\",\n seller: \"Seller\",\n you: \"You\",\n auctionRuns: \"The auction runs to its end — come back to claim the proceeds.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Рынок\",\n market: \"Рынок\",\n myLots: \"Мои лоты\",\n myBids: \"Мои ставки\",\n trades: \"Обмены\",\n claims: \"К получению\",\n history: \"История\",\n sell: \"Продать\",\n lots: \"лот|лота|лотов\",\n auctions: \"аукцион|аукциона|аукционов\",\n buy: \"Купить\",\n bid: \"Ставка\",\n yourBid: \"Ваша ставка\",\n currentBid: \"Текущая ставка\",\n startingBid: \"Начальная ставка\",\n leading: \"Вы лидируете\",\n endsIn: \"До конца\",\n listing: \"Фиксированная цена\",\n auction: \"Аукцион\",\n price: \"Цена\",\n amount: \"Количество\",\n duration: \"Срок\",\n hours: \"ч\",\n commission: \"Комиссия\",\n create: \"Выставить\",\n cancel: \"Снять\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n gift: \"Подарок\",\n wants: \"Просит\",\n from: \"От\",\n pickItem: \"Выберите предмет\",\n nothingToSell:\n \"Продавать нечего — здесь появятся ваши предметы, которыми можно торговать.\",\n noTradableCurrency:\n \"В этой игре пока нет валюты, которой игроки могут торговать между собой.\",\n empty: \"Пока пусто.\",\n closed: \"Рынок сейчас закрыт.\",\n disabled: \"Рынка в этой игре нет. Включите его в LiveOps → Marketplace.\",\n sold: \"Продано\",\n bought: \"Куплено\",\n seller: \"Продавец\",\n you: \"Вы\",\n auctionRuns: \"Аукцион идёт до конца — возвращайтесь забрать выручку.\",\n});\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "index.ts",
|
|
52
|
+
"content": "// @idosgames/mod-marketplace-ui — Marketplace screen for any game: browse lots by item, buy and bid, sell your items as listings or auctions, direct trades, claims and history.\n\nexport { marketplaceUiModule } from \"./module\";\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "MarketplaceScreen.tsx",
|
|
56
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace\n .getDefinitions()\n .then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.user.getUserInventory();\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyLeadingBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(0,0,0,.3)\",\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "model.test.ts",
|
|
60
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n auctionDurations,\n commissionPercent,\n groupCounts,\n listingDurations,\n marketplaceBadge,\n minNextBid,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n} from \"./model\";\n\nconst defs = (seed as unknown as { Marketplace: never }).Marketplace;\nconst coins = (amount: number) => ({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: amount }],\n});\n\ndescribe(\"marketplace\", () => {\n it(\"reads the seeded rules: 5% commission (a fraction in config), listing and auction durations\", () => {\n expect(commissionPercent(defs)).toBe(5);\n expect(listingDurations(defs)).toEqual([24, 72]);\n expect(auctionDurations(defs)).toEqual([1, 24, 72]);\n });\n\n it(\"next bid follows the engine: start price first, then current + max(ceil(5%), absolute)\", () => {\n expect(minNextBid({ Auction: { StartingBid: 40 } } as never, defs)).toBe(\n 40,\n );\n expect(\n minNextBid(\n {\n Auction: { StartingBid: 40, CurrentBid: 40, CurrentBidderID: \"u\" },\n } as never,\n defs,\n ),\n ).toBe(42);\n expect(\n minNextBid(\n { Auction: { CurrentBid: 10, CurrentBidderID: \"u\" } } as never,\n defs,\n ),\n ).toBe(11);\n });\n\n it(\"lists cheapest listings first, then auctions ending soonest\", () => {\n const sorted = sortOffers([\n { OfferID: \"a2\", OfferType: \"Auction\", ExpiresAt: \"2026-10-02\" },\n { OfferID: \"l50\", OfferType: \"Listing\", Price: coins(50) },\n { OfferID: \"a1\", OfferType: \"Auction\", ExpiresAt: \"2026-10-01\" },\n { OfferID: \"l20\", OfferType: \"Listing\", Price: coins(20) },\n ] as never);\n expect(sorted.map((o) => o.OfferID)).toEqual([\"l20\", \"l50\", \"a1\", \"a2\"]);\n });\n\n it(\"sells only tradable items and never a worn one\", () => {\n const items = new Map(\n Object.entries({\n potion: { IsStackable: true, IsTradable: true, CatalogID: \"Item\" },\n soulbound: { IsStackable: true, IsTradable: false },\n sword: { IsStackable: false, IsTradable: true, CatalogID: \"Item\" },\n }),\n );\n const out = sellableItems(\n {\n Items: {\n potion: { StackableAmount: 3 },\n soulbound: { StackableAmount: 9 },\n sword: { TotalAmount: 2 },\n },\n UnstackableItems: {\n s1: { ItemID: \"sword\", EquippedSlot: \"Weapon\" },\n s2: { ItemID: \"sword\" },\n },\n },\n items,\n );\n expect(out).toEqual([\n { itemID: \"potion\", catalogID: \"Item\", amount: 3, instances: [] },\n { itemID: \"sword\", catalogID: \"Item\", amount: 1, instances: [\"s2\"] },\n ]);\n });\n\n it(\"prices only in currencies tradable between players\", () => {\n expect(\n tradableCurrencies({\n CO: { Permissions: { IsTradable: true } },\n GE: { Permissions: {} },\n XX: null,\n }),\n ).toEqual([\"CO\"]);\n expect(priceBundle(\"CO\", 0.4)).toEqual({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 1 }],\n });\n });\n\n it(\"group counts come from CountsByType when the mirrors are absent\", () => {\n expect(groupCounts({ CountsByType: { Listing: 2, Auction: 0 } })).toEqual({\n listings: 2,\n auctions: 0,\n });\n expect(groupCounts({ ListingCount: 1, AuctionCount: 3 })).toEqual({\n listings: 1,\n auctions: 3,\n });\n });\n\n it(\"the badge counts things to claim and trades waiting for an answer\", () => {\n expect(marketplaceBadge({ Claimables: [1, 2], IncomingTrades: [1] })).toBe(\n 3,\n );\n expect(marketplaceBadge(null)).toBe(0);\n });\n});\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "model.ts",
|
|
64
|
+
"content": "// Pure helpers of the marketplace screen (Unity Alikhan/Marketplace) — covered by model.test.ts.\n// The server holds the escrow and settles every trade; these only decide what the screen shows.\n\nimport type {\n MarketplaceDefinitions,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport {\n bundleLines,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The price of a listing, as lines. */\nexport function offerPrice(offer: MarketplaceOfferView): ResourceLine[] {\n return bundleLines(offer.Price as never);\n}\n\n/** The single currency of a price (listings are priced in one currency in this screen). */\nexport function priceAmount(offer: MarketplaceOfferView): number {\n return offerPrice(offer)[0]?.amount ?? 0;\n}\n\n/** Offers for one item, cheapest listing first, then auctions ending soonest. */\nexport function sortOffers(\n offers: MarketplaceOfferView[],\n): MarketplaceOfferView[] {\n const listings = offers\n .filter((o) => o.OfferType === \"Listing\")\n .sort((a, b) => priceAmount(a) - priceAmount(b));\n const auctions = offers\n .filter((o) => o.OfferType === \"Auction\")\n .sort((a, b) =>\n String(a.ExpiresAt ?? \"\").localeCompare(String(b.ExpiresAt ?? \"\")),\n );\n return [\n ...listings,\n ...auctions,\n ...offers.filter(\n (o) => o.OfferType !== \"Listing\" && o.OfferType !== \"Auction\",\n ),\n ];\n}\n\n/** The lowest bid the server accepts next: the current bid plus the configured step (or the start). */\nexport function minNextBid(\n offer: MarketplaceOfferView,\n defs: MarketplaceDefinitions | null | undefined,\n): number {\n const a = offer.Auction;\n const current = Number(a?.CurrentBid ?? 0);\n if (!a || current <= 0 || !a.CurrentBidderID)\n return Math.max(1, Number(a?.StartingBid ?? 1));\n // The engine's rule: step = max(ceil(current × fraction), MinBidStepAbsolute); 0.05 = 5%.\n const fraction = Number(defs?.Auctions?.MinBidStepPercent ?? 0.05);\n const abs = Number(defs?.Auctions?.MinBidStepAbsolute ?? 0);\n const step = Math.max(1, abs, Math.ceil(current * fraction));\n return current + step;\n}\n\n/** Durations the title allows for a listing (hours), with a sane fallback. */\nexport function listingDurations(\n defs: MarketplaceDefinitions | null | undefined,\n): number[] {\n const allowed = (defs?.Listings?.AllowedDurationsHours ?? []).filter(\n (h) => h > 0,\n );\n return allowed.length > 0 ? [...allowed].sort((a, b) => a - b) : [24, 72];\n}\n\n/** Durations for an auction within the title's bounds. */\nexport function auctionDurations(\n defs: MarketplaceDefinitions | null | undefined,\n): number[] {\n const min = Math.max(1, Number(defs?.Auctions?.MinDurationHours ?? 1));\n const max = Math.max(min, Number(defs?.Auctions?.MaxDurationHours ?? 72));\n return [...new Set([min, Math.min(max, 24), max])]\n .filter((h) => h >= min && h <= max)\n .sort((a, b) => a - b);\n}\n\n/** Commission the seller pays, in percent for display. The config holds a FRACTION (0.05 = 5%). */\nexport function commissionPercent(\n defs: MarketplaceDefinitions | null | undefined,\n): number {\n return Math.round(Number(defs?.Commission?.Percent ?? 0.05) * 1000) / 10;\n}\n\nexport interface SellableItem {\n itemID: string;\n catalogID: string;\n amount: number;\n /** Unstackable: instance ids the player can put up (not equipped). */\n instances: string[];\n}\n\n/** Owned items the player may sell: tradable definitions, instances that are not worn. */\nexport function sellableItems(\n inventory:\n | {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; EquippedSlot?: unknown } | null\n > | null;\n }\n | null\n | undefined,\n items: Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >,\n): SellableItem[] {\n const out = new Map<string, SellableItem>();\n for (const [id, t] of Object.entries(inventory?.Items ?? {})) {\n const def = items.get(id);\n if (!def || def.IsTradable === false || def.IsStackable === false) continue;\n const amount = Number(t?.StackableAmount ?? t?.TotalAmount ?? 0);\n if (amount > 0)\n out.set(id, {\n itemID: id,\n catalogID: def.CatalogID ?? \"Item\",\n amount,\n instances: [],\n });\n }\n for (const [instanceID, inst] of Object.entries(\n inventory?.UnstackableItems ?? {},\n )) {\n if (!inst?.ItemID || inst.EquippedSlot) continue;\n const def = items.get(inst.ItemID);\n if (!def || def.IsTradable === false) continue;\n const entry = out.get(inst.ItemID) ?? {\n itemID: inst.ItemID,\n catalogID: def.CatalogID ?? \"Item\",\n amount: 0,\n instances: [],\n };\n entry.instances.push(instanceID);\n entry.amount = entry.instances.length;\n out.set(inst.ItemID, entry);\n }\n return [...out.values()].sort((a, b) => a.itemID.localeCompare(b.itemID));\n}\n\n/** Listings and auctions of a market group. The server fills CountsByType (the *Count fields are\n * optional mirrors of it), so both are read. */\nexport function groupCounts(g: {\n CountsByType?: Partial<Record<string, number>> | null;\n ListingCount?: number | null;\n AuctionCount?: number | null;\n}): { listings: number; auctions: number } {\n return {\n listings: Number(g.ListingCount ?? g.CountsByType?.Listing ?? 0),\n auctions: Number(g.AuctionCount ?? g.CountsByType?.Auction ?? 0),\n };\n}\n\n/** Currencies a lot can be priced in: the engine refuses one not tradable between players. */\nexport function tradableCurrencies(\n currencies: Record<\n string,\n { Permissions?: { IsTradable?: boolean | null } | null } | null | undefined\n >,\n): string[] {\n return Object.entries(currencies)\n .filter(([, c]) => c?.Permissions?.IsTradable === true)\n .map(([id]) => id);\n}\n\n/** A price bundle of one currency. */\nexport function priceBundle(\n currencyID: string,\n amount: number,\n): {\n Entries: Array<{\n Type: \"VirtualCurrency\";\n CurrencyID: string;\n Amount: number;\n }>;\n} {\n return {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: currencyID,\n Amount: Math.max(1, Math.floor(amount)),\n },\n ],\n };\n}\n\n/** Badge of the marketplace: things to claim + incoming trades waiting for an answer. */\nexport function marketplaceBadge(\n state:\n | { Claimables?: unknown[] | null; IncomingTrades?: unknown[] | null }\n | null\n | undefined,\n): number {\n return (\n (state?.Claimables?.length ?? 0) + (state?.IncomingTrades?.length ?? 0)\n );\n}\n"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "module.ts",
|
|
68
|
+
"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 { makeMarketplaceScreen } from \"./MarketplaceScreen\";\nimport { marketplaceBadge } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace as a feature of the game. Badge: things to claim (won lots, expired escrow) and\n// incoming trades waiting for an answer — read once at start and after every marketplace action.\nexport const marketplaceUiModule: Module = defineModule({\n id: \"marketplace-ui\",\n meta: { name: \"Marketplace\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"marketplace\",\n label: t(\"title\"),\n icon: \"market\",\n group: \"economy\",\n order: 70,\n Screen: makeMarketplaceScreen(ctx.features),\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: \"marketplace\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function MarketplaceBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n // A title without the section has no marketplace — no request at all.\n if (!configSection(client, \"Marketplace\")) return;\n const check = () =>\n void client.marketplace\n .getMyState()\n .then(\n (res) =>\n res.ok &&\n features.setBadge(\"marketplace\", marketplaceBadge(res.data)),\n );\n check();\n const id = setInterval(check, 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "module.meta.json",
|
|
72
|
+
"content": "{\n \"id\": \"marketplace-ui\",\n \"type\": \"feature\",\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|