@idosgames/mcp 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +69 -25
  4. package/registry/index.json +895 -620
  5. package/registry/modules/ai.json +65 -0
  6. package/registry/modules/board-game.json +8 -8
  7. package/registry/modules/boosts.json +66 -0
  8. package/registry/modules/{character-ui.json → character.json} +8 -12
  9. package/registry/modules/chat.json +70 -0
  10. package/registry/modules/collections.json +69 -0
  11. package/registry/modules/coop-events.json +67 -0
  12. package/registry/modules/{craft-ui.json → craft.json} +8 -12
  13. package/registry/modules/{deal-offer-ui.json → deals.json} +7 -11
  14. package/registry/modules/events.json +72 -0
  15. package/registry/modules/{friends-ui.json → friends.json} +11 -14
  16. package/registry/modules/idle-rpg.json +7 -7
  17. package/registry/modules/inventory.json +67 -0
  18. package/registry/modules/invite.json +70 -0
  19. package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -12
  20. package/registry/modules/lootboxes.json +67 -0
  21. package/registry/modules/mailbox.json +71 -0
  22. package/registry/modules/marketplace.json +71 -0
  23. package/registry/modules/mini-game.json +69 -0
  24. package/registry/modules/multiplayer.json +74 -0
  25. package/registry/modules/premium.json +67 -0
  26. package/registry/modules/profile.json +70 -0
  27. package/registry/modules/{pvp-ui.json → pvp.json} +9 -13
  28. package/registry/modules/quests.json +69 -0
  29. package/registry/modules/rewards.json +66 -0
  30. package/registry/modules/season.json +71 -0
  31. package/registry/modules/{store-ui.json → store.json} +9 -13
  32. package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -12
  33. package/registry/modules/voxelcraft.json +15 -455
  34. package/registry/modules/workshop.json +10 -6
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/dev-test-loop.json +1 -1
  37. package/registry/skills/idosgames-compose-modules.json +2 -2
  38. package/registry/skills/idosgames-getting-started.json +2 -2
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +2 -2
  42. package/registry/skills/mailbox-system.json +6 -0
  43. package/registry/skills/multiplayer-realtime.json +2 -2
  44. package/registry/skills/push-notifications.json +1 -1
  45. package/registry/skills/workshop-system.json +1 -1
  46. package/registry/modules/chat-ui.json +0 -74
  47. package/registry/modules/events-ui.json +0 -80
  48. package/registry/modules/game-hud.json +0 -104
  49. package/registry/modules/inventory-ui.json +0 -73
  50. package/registry/modules/marketplace-ui.json +0 -75
  51. package/registry/modules/profile-ui.json +0 -74
  52. package/registry/modules/referral-ui.json +0 -73
  53. package/registry/modules/rewards-ui.json +0 -75
  54. package/registry/modules/season-ui.json +0 -79
  55. package/registry/modules/showcase.json +0 -92
@@ -1,73 +0,0 @@
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
- }
@@ -1,75 +0,0 @@
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
- }
@@ -1,74 +0,0 @@
1
- {
2
- "id": "profile-ui",
3
- "meta": {
4
- "name": "Profile",
5
- "type": "app",
6
- "engine": "dom"
7
- },
8
- "catalog": {
9
- "type": "feature",
10
- "summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.",
11
- "description": "A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorial-ui is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the showcase lobby's profile button opens it. No title settings needed.",
12
- "provides": [
13
- "player name and ID",
14
- "rename popup",
15
- "sound, volume and fewer animations",
16
- "language picker",
17
- "analytics consent",
18
- "log out",
19
- "account deletion with confirmation"
20
- ],
21
- "tags": [
22
- "profile",
23
- "settings",
24
- "account",
25
- "sound",
26
- "language",
27
- "privacy",
28
- "ui",
29
- "screen"
30
- ],
31
- "author": {
32
- "name": "iDos Games",
33
- "url": "https://idosgames.com"
34
- },
35
- "version": "0.1.0"
36
- },
37
- "dependencies": {
38
- "@idosgames/core": "0.19.0",
39
- "@idosgames/module-sdk": "0.5.0",
40
- "@idosgames/react": "0.3.0",
41
- "react": "19.2.7"
42
- },
43
- "contentHash": "d5044c75012bd4f39b2156237dd036f26d68e3ec9f25b4d5e840110871ff0fc8",
44
- "files": [
45
- {
46
- "path": "i18n.ts",
47
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Profile\",\n playerID: \"Player ID\",\n copied: \"Copied\",\n rename: \"Change name\",\n newName: \"New name\",\n save: \"Save\",\n nameShort: \"At least 3 characters\",\n nameLong: \"At most 24 characters\",\n renamed: \"Name changed\",\n settings: \"Settings\",\n sound: \"Sound\",\n volume: \"Volume\",\n motion: \"Fewer animations\",\n language: \"Language\",\n analytics: \"Share anonymous usage data\",\n tutorials: \"Tutorials\",\n logout: \"Log out\",\n logoutAsk:\n \"Log out of this device? A guest account is lost unless it is linked.\",\n delete: \"Delete account\",\n deleteAsk:\n \"This deletes your progress in this game for good. Type the word to confirm:\",\n deleteWord: \"DELETE\",\n deleted: \"Account deleted\",\n on: \"On\",\n off: \"Off\",\n guest: \"Guest\",\n wallets: \"Wallets\",\n unlink: \"Unlink\",\n unlinkTitle: \"Unlink the wallet?\",\n unlinkAsk:\n \"The wallet will be taken off this account. For 7 days it cannot be linked to another account; you can link it back here at any time. If the game is played with a wallet, it closes until you link one.\",\n unlinked: \"Wallet unlinked\",\n unlinkLogin: \"You sign in with this wallet — it cannot be unlinked.\",\n unlinkTooMany:\n \"Too many wallets unlinked this week. Try again in a few days.\",\n unlinkMissing: \"This wallet is no longer linked.\",\n loginWallet: \"Sign-in wallet\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Профиль\",\n playerID: \"ID игрока\",\n copied: \"Скопировано\",\n rename: \"Сменить имя\",\n newName: \"Новое имя\",\n save: \"Сохранить\",\n nameShort: \"Минимум 3 символа\",\n nameLong: \"Максимум 24 символа\",\n renamed: \"Имя изменено\",\n settings: \"Настройки\",\n sound: \"Звук\",\n volume: \"Громкость\",\n motion: \"Меньше анимации\",\n language: \"Язык\",\n analytics: \"Делиться анонимной статистикой\",\n tutorials: \"Обучение\",\n logout: \"Выйти\",\n logoutAsk:\n \"Выйти на этом устройстве? Гостевой аккаунт без привязки будет потерян.\",\n delete: \"Удалить аккаунт\",\n deleteAsk:\n \"Весь ваш прогресс в этой игре будет удалён навсегда. Чтобы подтвердить, введите слово:\",\n deleteWord: \"УДАЛИТЬ\",\n deleted: \"Аккаунт удалён\",\n on: \"Вкл\",\n off: \"Выкл\",\n guest: \"Гость\",\n wallets: \"Кошельки\",\n unlink: \"Отвязать\",\n unlinkTitle: \"Отвязать кошелёк?\",\n unlinkAsk:\n \"Кошелёк будет снят с этого аккаунта. 7 дней его нельзя привязать к другому аккаунту, а к этому — можно вернуть в любой момент. Если в игру играют с кошельком, она закроется, пока вы не привяжете другой.\",\n unlinked: \"Кошелёк отвязан\",\n unlinkLogin: \"Вы входите этим кошельком — его нельзя отвязать.\",\n unlinkTooMany:\n \"На этой неделе отвязано слишком много кошельков. Попробуйте через несколько дней.\",\n unlinkMissing: \"Этот кошелёк уже не привязан.\",\n loginWallet: \"Кошелёк входа\",\n});\n"
48
- },
49
- {
50
- "path": "index.ts",
51
- "content": "// @idosgames/mod-profile-ui — Profile for any game: name, ID, language, sound and motion settings, analytics consent, log out and account deletion.\n\nexport { profileUiModule } from \"./module\";\n"
52
- },
53
- {
54
- "path": "model.test.ts",
55
- "content": "import { describe, expect, it } from \"vitest\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\n\ndescribe(\"profile\", () => {\n it(\"applies the server's name rules: trimmed, 3–24 characters\", () => {\n expect(nameProblem(\" An \")).toBe(\"short\");\n expect(nameProblem(\" Ann \")).toBeNull();\n expect(nameProblem(\"Anna\")).toBeNull();\n expect(nameProblem(\"x\".repeat(25))).toBe(\"long\");\n });\n\n it(\"shows the volume as a clamped percentage\", () => {\n expect(volumePercent(0.35)).toBe(35);\n expect(volumePercent(2)).toBe(100);\n expect(volumePercent(undefined)).toBe(100);\n });\n\n it(\"deletion unlocks only on the typed word, in any case\", () => {\n expect(deleteUnlocked(\" удалить \", \"УДАЛИТЬ\")).toBe(true);\n expect(deleteUnlocked(\"удали\", \"УДАЛИТЬ\")).toBe(false);\n });\n\n it(\"shortens a wallet address to its ends\", () => {\n expect(shortAddress(\"0x1234567890abcdef1234567890abcdef12345678\")).toBe(\n \"0x1234…5678\",\n );\n expect(shortAddress(\"0xabc\")).toBe(\"0xabc\");\n expect(shortAddress(null)).toBe(\"\");\n });\n\n it(\"names the server's unlink refusals and leaves the rest as they are\", () => {\n expect(unlinkRefusal(\"WALLET_IS_LOGIN_WALLET\")).toBe(\"unlinkLogin\");\n expect(unlinkRefusal(\"WALLET_CHANGES_TOO_FREQUENT\")).toBe(\"unlinkTooMany\");\n expect(unlinkRefusal(\"WALLET_NOT_LINKED\")).toBe(\"unlinkMissing\");\n expect(unlinkRefusal(\"RATE_LIMIT_EXCEEDED\")).toBeNull();\n });\n\n it(\"lists the title's languages in its order\", () => {\n expect(\n languageChoices([\n { Locale: \"ru\", DisplayName: \"Русский\", Order: 1 },\n { Locale: \"en\", DisplayName: \"English\", Order: 0 },\n { Locale: null },\n ]),\n ).toEqual([\n { id: \"en\", name: \"English\" },\n { id: \"ru\", name: \"Русский\" },\n ]);\n });\n});\n"
56
- },
57
- {
58
- "path": "model.ts",
59
- "content": "// Pure helpers of the profile screen (Unity Alikhan/Username, UI/HUD/Settings): the name rules the\n// server applies, the volume as a percentage, and the typed word that unlocks account deletion.\n// Covered by model.test.ts.\n\nexport const NAME_MIN = 3;\nexport const NAME_MAX = 24;\n\n/** The server trims and wants 3–24 characters; `null` = fine. */\nexport function nameProblem(name: string): \"short\" | \"long\" | null {\n const n = name.trim().length;\n if (n < NAME_MIN) return \"short\";\n if (n > NAME_MAX) return \"long\";\n return null;\n}\n\nexport function volumePercent(volume: number | null | undefined): number {\n return Math.round(Math.min(1, Math.max(0, Number(volume ?? 1))) * 100);\n}\n\n/** Deleting an account can't be undone — the player types the word to unlock the button. */\nexport function deleteUnlocked(typed: string, word: string): boolean {\n return typed.trim().toUpperCase() === word.toUpperCase();\n}\n\n/** \"0x1234…abcd\" — enough to recognise a wallet without a line-wide hash. */\nexport function shortAddress(address: string | null | undefined): string {\n const a = (address ?? \"\").trim();\n return a.length > 14 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a;\n}\n\n/** The server's refusal of an unlink → the key of a sentence the player understands; null — show as is. */\nexport function unlinkRefusal(\n code: string | null | undefined,\n): \"unlinkLogin\" | \"unlinkTooMany\" | \"unlinkMissing\" | null {\n switch (code) {\n case \"WALLET_IS_LOGIN_WALLET\":\n return \"unlinkLogin\";\n case \"WALLET_CHANGES_TOO_FREQUENT\":\n return \"unlinkTooMany\";\n case \"WALLET_NOT_LINKED\":\n return \"unlinkMissing\";\n default:\n return null;\n }\n}\n\n/** Languages the title offers, in its order; the picker shows them only when there are two or more. */\nexport function languageChoices(\n locales:\n | ReadonlyArray<{\n Locale?: string | null;\n DisplayName?: string | null;\n Order?: number | null;\n }>\n | null\n | undefined,\n): Array<{ id: string; name: string }> {\n return [...(locales ?? [])]\n .filter((l) => l.Locale)\n .sort((a, b) => Number(a.Order ?? 0) - Number(b.Order ?? 0))\n .map((l) => ({ id: l.Locale!, name: l.DisplayName || l.Locale! }));\n}\n"
60
- },
61
- {
62
- "path": "module.ts",
63
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeProfileScreen } from \"./ProfileScreen\";\nimport { t } from \"./i18n\";\n\n// Profile and settings as a feature of the game. The showcase lobby opens it from its profile\n// button (features.open(\"profile\")); the shared HUD lists it in its ☰.\nexport const profileUiModule: Module = defineModule({\n id: \"profile-ui\",\n meta: { name: \"Profile\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"profile\",\n label: t(\"title\"),\n icon: \"user\",\n group: \"profile\",\n order: 100,\n Screen: makeProfileScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"profile\" }),\n describeActions: {},\n });\n },\n});\n"
64
- },
65
- {
66
- "path": "ProfileScreen.tsx",
67
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type { PlayAccessWallet } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n Icon,\n Popup,\n Tabs,\n errorText,\n outlined,\n panel,\n usePlayerPrefs,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Profile and settings (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization):\n// the name and ID, sound, volume and motion (kept on this device), the game's language, analytics\n// consent, tutorials, linked wallets, logging out and deleting the account. Renaming, unlinking a\n// wallet and deleting are popups.\n\nexport function makeProfileScreen(features: FeatureRegistry) {\n return function ProfileScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [prefs, setPrefs] = usePlayerPrefs();\n const [name, setName] = useState<string | null>(null);\n const [renaming, setRenaming] = useState(false);\n const [ask, setAsk] = useState<\"logout\" | \"delete\" | null>(null);\n const [locale, setLocale] = useState(client.localization.locale ?? \"\");\n const [analytics, setAnalytics] = useState(client.analytics.isEnabled);\n const id = client.auth.context?.userID ?? \"\";\n const shown = name ?? state?.PublicData?.Username ?? t(\"guest\");\n const languages = languageChoices(client.localization.locales);\n\n const switchLanguage = async (next: string) => {\n const res = await client.localization.setLocale(next);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setLocale(res.data);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n maxWidth: 560,\n margin: \"0 auto\",\n width: \"100%\",\n }}\n >\n <div\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n }}\n >\n <div\n style={{\n width: 84,\n height: 84,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"3px solid rgba(255,255,255,.7)\",\n ...outlined,\n fontSize: 36,\n }}\n >\n {(shown.trim()[0] ?? \"?\").toUpperCase()}\n </div>\n <div style={{ ...outlined, fontSize: 20 }}>{shown}</div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setRenaming(true)}\n data-tutorial-anchor=\"profile:rename\"\n >\n <Icon glyph=\"edit\" size={16} /> {t(\"rename\")}\n </Button>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n maxWidth: \"100%\",\n }}\n >\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"playerID\")}\n </span>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 12,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {id}\n </span>\n <button\n type=\"button\"\n aria-label={t(\"playerID\")}\n onClick={() =>\n void navigator.clipboard\n ?.writeText(id)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n padding: 2,\n }}\n >\n <Icon glyph=\"copy\" size={16} />\n </button>\n </div>\n </div>\n\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 12 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"settings\")}\n </div>\n <Toggle\n glyph={prefs.muted ? \"mute\" : \"sound\"}\n label={t(\"sound\")}\n value={!prefs.muted}\n onChange={(on) => {\n setPrefs({ muted: !on });\n if (on) kit.play(\"click\");\n }}\n />\n <div\n style={{ display: \"grid\", gap: 4, opacity: prefs.muted ? 0.5 : 1 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>{t(\"volume\")}</span>\n <span>{volumePercent(prefs.volume)}%</span>\n </div>\n <input\n type=\"range\"\n min={0}\n max={100}\n step={5}\n disabled={prefs.muted}\n value={volumePercent(prefs.volume)}\n onChange={(e) =>\n setPrefs({ volume: Number(e.target.value) / 100 })\n }\n onPointerUp={() => kit.play(\"coin\")}\n style={{ width: \"100%\", accentColor: v.gold }}\n />\n </div>\n <Toggle\n glyph=\"flame\"\n label={t(\"motion\")}\n value={prefs.reducedMotion}\n onChange={(on) => setPrefs({ reducedMotion: on })}\n />\n <Toggle\n glyph=\"globe\"\n label={t(\"analytics\")}\n value={analytics}\n onChange={(on) => {\n client.analytics.setEnabled(on);\n setAnalytics(client.analytics.isEnabled);\n }}\n />\n {languages.length > 1 ? (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"language\")}</div>\n <Tabs\n tabs={languages.map((l) => ({ id: l.id, label: l.name }))}\n value={\n languages.find((l) => locale.startsWith(l.id))?.id ??\n languages[0]!.id\n }\n onChange={(l) => void switchLanguage(l)}\n />\n </div>\n ) : null}\n </div>\n\n <WalletsSection />\n\n {features.get(\"tutorials\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"tutorials\")}>\n <Icon glyph=\"bulb\" size={18} /> {t(\"tutorials\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={() => setAsk(\"logout\")}>\n <Icon glyph=\"logout\" size={18} /> {t(\"logout\")}\n </Button>\n <Button tone=\"red\" onClick={() => setAsk(\"delete\")}>\n <Icon glyph=\"trash\" size={18} /> {t(\"delete\")}\n </Button>\n\n {renaming ? (\n <RenamePopup\n current={shown}\n onClose={() => setRenaming(false)}\n onDone={(n) => {\n setName(n);\n setRenaming(false);\n }}\n />\n ) : null}\n {ask === \"logout\" ? (\n <ConfirmPopup\n title={t(\"logout\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => {\n client.auth.logout();\n location.reload();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"logoutAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {ask === \"delete\" ? <DeletePopup onClose={() => setAsk(null)} /> : null}\n </div>\n );\n };\n}\n\nfunction Toggle({\n glyph,\n label,\n value,\n onChange,\n}: {\n glyph: \"sound\" | \"mute\" | \"flame\" | \"globe\";\n label: string;\n value: boolean;\n onChange: (on: boolean) => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n aria-checked={value}\n onClick={() => onChange(!value)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n background: \"none\",\n border: \"none\",\n color: v.text,\n cursor: \"pointer\",\n padding: 0,\n textAlign: \"left\",\n }}\n >\n <Icon glyph={glyph} size={24} />\n <span style={{ flex: 1, ...outlined, fontSize: 14 }}>{label}</span>\n <span\n style={{\n width: 52,\n height: 30,\n borderRadius: 15,\n background: value ? v.green : \"rgba(255,255,255,.2)\",\n position: \"relative\",\n transition: \"background .2s\",\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: 3,\n left: value ? 25 : 3,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n background: \"#fff\",\n transition: \"left .2s\",\n boxShadow: \"0 2px 4px rgba(0,0,0,.3)\",\n }}\n />\n </span>\n </button>\n );\n}\n\n/**\n * Wallets linked by signature, each with \"Unlink\" (`client.auth.unlinkWallet`). Hidden while there are\n * none. The wallet an account signs in with has no button: the server refuses it (`CanUnlink`).\n */\nfunction WalletsSection(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [wallets, setWallets] = useState<PlayAccessWallet[]>([]);\n const [asking, setAsking] = useState<PlayAccessWallet | null>(null);\n\n const load = useCallback(async () => {\n const res = await client.blockchain.getPlayAccess();\n if (res.ok) setWallets(res.data.LinkedWallets ?? []);\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n // The popup shows the spinner and closes itself when this resolves.\n const unlink = async (wallet: PlayAccessWallet) => {\n const res = await client.auth.unlinkWallet(wallet.NetworkID ?? \"\");\n if (!res.ok) {\n const known = unlinkRefusal(res.error);\n toast(known ? t(known) : errorText(res.error), \"error\");\n } else toast(t(\"unlinked\"), \"success\");\n await load();\n };\n\n if (wallets.length === 0) return null;\n return (\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"wallets\")}\n </div>\n {wallets.map((w) => (\n <div\n key={`${w.NetworkID}:${w.Address}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n minWidth: 0,\n }}\n >\n <Icon glyph=\"link\" size={22} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\" }}>\n <span\n style={{\n fontFamily: \"monospace\",\n fontSize: 13,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {shortAddress(w.Address)}\n </span>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {(w.NetworkID ?? \"\").toUpperCase()}\n {w.CanUnlink === false ? ` · ${t(\"loginWallet\")}` : \"\"}\n </span>\n </div>\n {w.CanUnlink !== false ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setAsking(w)}>\n {t(\"unlink\")}\n </Button>\n ) : null}\n </div>\n ))}\n {asking ? (\n <ConfirmPopup\n title={t(\"unlinkTitle\")}\n confirmLabel={t(\"unlink\")}\n tone=\"red\"\n onClose={() => setAsking(null)}\n onConfirm={() => unlink(asking)}\n >\n <div\n style={{\n fontFamily: \"monospace\",\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {shortAddress(asking.Address)}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n lineHeight: 1.4,\n }}\n >\n {t(\"unlinkAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction RenamePopup({\n current,\n onClose,\n onDone,\n}: {\n current: string;\n onClose: () => void;\n onDone: (name: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [value, setValue] = useState(current);\n const [busy, setBusy] = useState(false);\n const problem = nameProblem(value);\n const save = async () => {\n if (problem) return;\n setBusy(true);\n const res = await client.user.changeUsername(value.trim());\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"renamed\"), \"success\");\n // The SDK does not patch the cached name — the screen shows the server's answer.\n onDone(res.data.Username ?? value.trim());\n };\n return (\n <Popup title={t(\"rename\")} onClose={onClose} width={400}>\n <input\n value={value}\n autoFocus\n maxLength={40}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void save()}\n placeholder={t(\"newName\")}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n }}\n />\n {problem ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {problem === \"short\" ? t(\"nameShort\") : t(\"nameLong\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!!problem}\n busy={busy}\n onClick={() => void save()}\n >\n {t(\"save\")}\n </Button>\n </Popup>\n );\n}\n\nfunction DeletePopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [typed, setTyped] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const word = t(\"deleteWord\");\n const remove = async () => {\n setBusy(true);\n const res = await client.user.deleteUserAccount();\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"deleted\"));\n client.auth.logout();\n location.reload();\n };\n return (\n <Popup title={t(\"delete\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteAsk\")} <span style={{ color: v.red }}>{word}</span>\n </div>\n <input\n value={typed}\n onChange={(e) => setTyped(e.target.value)}\n placeholder={word}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 16,\n color: v.text,\n outline: \"none\",\n textAlign: \"center\",\n }}\n />\n <Button\n tone=\"red\"\n disabled={!deleteUnlocked(typed, word)}\n busy={busy}\n onClick={() => void remove()}\n >\n {t(\"delete\")}\n </Button>\n </Popup>\n );\n}\n"
68
- },
69
- {
70
- "path": "module.meta.json",
71
- "content": "{\n \"id\": \"profile-ui\",\n \"type\": \"feature\",\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorial-ui is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the showcase lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"analytics consent\",\n \"log out\",\n \"account deletion with confirmation\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
72
- }
73
- ]
74
- }
@@ -1,73 +0,0 @@
1
- {
2
- "id": "referral-ui",
3
- "meta": {
4
- "name": "Invite",
5
- "type": "app",
6
- "engine": "dom"
7
- },
8
- "catalog": {
9
- "type": "feature",
10
- "summary": "Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.",
11
- "description": "A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.",
12
- "provides": [
13
- "own invite code and link",
14
- "share or copy the invite",
15
- "friends who came by your code",
16
- "invite reward ladder with claim all",
17
- "enter a friend's code popup",
18
- "invite badge",
19
- "demo referral rewards"
20
- ],
21
- "tags": [
22
- "referral",
23
- "invite",
24
- "friends",
25
- "growth",
26
- "social",
27
- "ui",
28
- "screen"
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": "f78e3211a36e341d0e6a338b21b82d3b0084b975a4157eaee80ebea2e814e396",
43
- "files": [
44
- {
45
- "path": "i18n.ts",
46
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Invite\",\n disabled: \"Invites are turned off in this game.\",\n yourCode: \"Your invite code\",\n copyCode: \"Copy code\",\n share: \"Share link\",\n copied: \"Copied\",\n joined: \"Friends who came by your code\",\n rewards: \"Rewards for friends\",\n friends: \"friend|friends\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n enterCode: \"Enter a friend's code\",\n enterHint: \"Got a code from a friend? Enter it and you both get a gift.\",\n youGet: \"You get\",\n activate: \"Activate\",\n badCode: \"A code is 8 letters, like WDJB-MJHT\",\n activated: \"Code activated!\",\n youCameBy: \"You joined by a friend's code\",\n inviteText: \"Join me in the game! My code:\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Пригласить\",\n disabled: \"Приглашения в этой игре выключены.\",\n yourCode: \"Ваш код приглашения\",\n copyCode: \"Скопировать код\",\n share: \"Поделиться ссылкой\",\n copied: \"Скопировано\",\n joined: \"Пришли по вашему коду\",\n rewards: \"Награды за друзей\",\n friends: \"друг|друга|друзей\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n enterCode: \"Ввести код друга\",\n enterHint: \"Друг дал вам код? Введите его — подарок получите вы оба.\",\n youGet: \"Вы получите\",\n activate: \"Активировать\",\n badCode: \"Код — это 8 букв, например WDJB-MJHT\",\n activated: \"Код активирован!\",\n youCameBy: \"Вы пришли по коду друга\",\n inviteText: \"Заходи в игру! Мой код:\",\n});\n"
47
- },
48
- {
49
- "path": "index.ts",
50
- "content": "// @idosgames/mod-referral-ui — Invite friends for any game: your code and link, entering a friend's code, and rewards for friends who joined.\n\nexport { referralUiModule } from \"./module\";\n"
51
- },
52
- {
53
- "path": "model.test.ts",
54
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { inviteRows, normalizeCode, prettyCode, referralBadge } from \"./model\";\n\nconst defs = (\n seed as unknown as {\n Referral: { IsEnabled: boolean; InviteRewards: Record<string, never> };\n }\n).Referral;\n\ndescribe(\"invite\", () => {\n it(\"the seeded ladder: 1, 3 and 5 friends\", () => {\n expect(defs.IsEnabled).toBe(true);\n const rows = inviteRows(defs.InviteRewards, { FollowersCount: 0 });\n expect(rows.map((r) => r.required)).toEqual([1, 3, 5]);\n expect(rows[0]!.reward).toEqual([\n { kind: \"currency\", id: \"GE\", amount: 10 },\n ]);\n expect(rows.every((r) => r.state === \"locked\")).toBe(true);\n });\n\n it(\"friends who came open steps; a claimed step stays claimed\", () => {\n const rows = inviteRows(defs.InviteRewards, {\n FollowersCount: 3,\n InviteRewardStates: { invite_1: { IsClaimed: true } },\n });\n expect(rows.map((r) => r.state)).toEqual([\"claimed\", \"ready\", \"locked\"]);\n expect(referralBadge(rows)).toBe(1);\n });\n\n it(\"reads a typed code the way the server does: separators dropped, upper case, its alphabet only\", () => {\n expect(normalizeCode(\"wdjb-mjht\")).toBe(\"WDJBMJHT\");\n expect(normalizeCode(\" WDJB MJHT \")).toBe(\"WDJBMJHT\");\n expect(normalizeCode(\"WDJBMJH\")).toBeNull();\n expect(normalizeCode(\"ABCDEFGH\")).toBeNull(); // vowels are not in the alphabet\n expect(normalizeCode(\"B0CDFGHJ\")).toBeNull();\n expect(prettyCode(\"WDJBMJHT\")).toBe(\"WDJB-MJHT\");\n });\n});\n"
55
- },
56
- {
57
- "path": "model.ts",
58
- "content": "// Pure helpers of the invite screen (Unity V2/Runtime/UI/Referral): the code a player types, the\n// ladder of rewards for friends who came by your code, and the badge. The server validates codes,\n// counts friends and pays — this only decides what the screen shows. Covered by model.test.ts.\n\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n/** The code alphabet: no vowels, no 0/O, no 1/I/L — so a code read aloud is typed right. */\nconst CODE_ALPHABET = /^[BCDFGHJKMNPQRSTVWXZ]{8}$/;\n\n/** A code as the player typed it: separators dropped, upper case; `null` when it can't be a code. */\nexport function normalizeCode(input: string): string | null {\n const code = input.replace(/[\\s\\-_.]/g, \"\").toUpperCase();\n return CODE_ALPHABET.test(code) ? code : null;\n}\n\n/** \"WDJB-MJHT\" — easier to read out and copy by eye. */\nexport function prettyCode(code: string | null | undefined): string {\n if (!code) return \"\";\n return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n}\n\nexport interface InviteRow {\n id: string;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function inviteRows(\n rewards:\n | Record<\n string,\n {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: unknown;\n } | null\n >\n | null\n | undefined,\n state:\n | {\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n }\n | null\n | undefined,\n): InviteRow[] {\n const friends = Number(state?.FollowersCount ?? 0);\n return Object.entries(rewards ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const id = m!.MilestoneID || key;\n const required = Number(m!.RequiredProgress ?? 0);\n const claimed = state?.InviteRewardStates?.[id]?.IsClaimed === true;\n return {\n id,\n name: m!.DisplayName ?? null,\n required,\n reward: grantLines(m!.Rewards as never),\n state: claimed ? \"claimed\" : friends >= required ? \"ready\" : \"locked\",\n } satisfies InviteRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** Badge: invite rewards ready to take. */\nexport function referralBadge(rows: InviteRow[]): number {\n return rows.filter((r) => r.state === \"ready\").length;\n}\n"
59
- },
60
- {
61
- "path": "module.ts",
62
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes.\nexport const referralUiModule: Module = defineModule({\n id: \"referral-ui\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n Screen: makeReferralScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
63
- },
64
- {
65
- "path": "ReferralScreen.tsx",
66
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n plural,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current?.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n });\n }, [client]);\n useEffect(() => {\n if (defs.current)\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n }, [state?.Referral]);\n return null;\n };\n}\n\nexport function makeReferralScreen(_features: FeatureRegistry) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || defs.IsEnabled === false)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #2fb36b 0%, #146b3d 100%)\",\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>\n {t(\"joined\")}\n </div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
67
- },
68
- {
69
- "path": "module.meta.json",
70
- "content": "{\n \"id\": \"referral-ui\",\n \"type\": \"feature\",\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
71
- }
72
- ]
73
- }