@idosgames/mcp 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +74 -22
  4. package/registry/index.json +761 -115
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/character.json +75 -0
  7. package/registry/modules/chat.json +71 -0
  8. package/registry/modules/craft.json +71 -0
  9. package/registry/modules/deals.json +70 -0
  10. package/registry/modules/events.json +77 -0
  11. package/registry/modules/friends.json +70 -0
  12. package/registry/modules/idle-rpg.json +8 -8
  13. package/registry/modules/inventory.json +70 -0
  14. package/registry/modules/invite.json +70 -0
  15. package/registry/modules/leaderboard.json +72 -0
  16. package/registry/modules/marketplace.json +72 -0
  17. package/registry/modules/mini-game.json +69 -0
  18. package/registry/modules/profile.json +71 -0
  19. package/registry/modules/pvp.json +69 -0
  20. package/registry/modules/rewards.json +72 -0
  21. package/registry/modules/season.json +76 -0
  22. package/registry/modules/store.json +72 -0
  23. package/registry/modules/tutorials.json +68 -0
  24. package/registry/modules/voxelcraft.json +65 -464
  25. package/registry/modules/workshop.json +13 -8
  26. package/registry/skills/dev-test-loop.json +1 -1
  27. package/registry/skills/idosgames-compose-modules.json +2 -2
  28. package/registry/skills/idosgames-getting-started.json +2 -2
  29. package/registry/skills/idosgames-module-contract.json +2 -2
  30. package/registry/skills/idosgames-project-structure.json +1 -1
  31. package/registry/skills/idosgames-ui-kit.json +6 -0
  32. package/registry/skills/leaderboard-system.json +1 -1
  33. package/registry/skills/multiplayer-realtime.json +3 -3
  34. package/registry/skills/push-notifications.json +1 -1
  35. package/registry/skills/referral-system.json +2 -2
  36. package/registry/skills/timed-event-system.json +1 -1
  37. package/registry/skills/workshop-system.json +1 -1
  38. package/registry/modules/game-hud.json +0 -99
@@ -0,0 +1,72 @@
1
+ {
2
+ "id": "marketplace",
3
+ "meta": {
4
+ "name": "Marketplace",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.",
12
+ "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.",
13
+ "provides": [
14
+ "player marketplace screen",
15
+ "listings and auctions",
16
+ "sell popup with price and duration",
17
+ "bids with the minimum next bid",
18
+ "direct trades",
19
+ "claims and history",
20
+ "marketplace badge",
21
+ "demo marketplace rules"
22
+ ],
23
+ "tags": [
24
+ "marketplace",
25
+ "trading",
26
+ "auction",
27
+ "listing",
28
+ "p2p",
29
+ "ui",
30
+ "screen",
31
+ "economy"
32
+ ],
33
+ "author": {
34
+ "name": "iDos Games",
35
+ "url": "https://idosgames.com"
36
+ },
37
+ "version": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "@idosgames/core": "0.19.0",
41
+ "@idosgames/module-sdk": "0.6.0",
42
+ "@idosgames/react": "0.3.0",
43
+ "react": "19.2.7"
44
+ },
45
+ "contentHash": "f9cd6340a024f37d6be7101f08dbf9aafbf1a8a52aa05f7ff1f2f43db3bc071d",
46
+ "files": [
47
+ {
48
+ "path": "i18n.ts",
49
+ "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"
50
+ },
51
+ {
52
+ "path": "index.ts",
53
+ "content": "// @idosgames/mod-marketplace — 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 { marketplaceModule } from \"./module\";\n"
54
+ },
55
+ {
56
+ "path": "MarketplaceScreen.tsx",
57
+ "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.getDefinitions().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"
58
+ },
59
+ {
60
+ "path": "model.ts",
61
+ "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\n/** Whether the title's marketplace is set up and not switched off — else the lobby hides the tab. */\nexport function hasTitleData(\n section: { Enabled?: boolean | null } | null | undefined,\n): boolean {\n return section != null && section.Enabled !== false;\n}\n"
62
+ },
63
+ {
64
+ "path": "module.ts",
65
+ "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 { hasTitleData, 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 marketplaceModule: Module = defineModule({\n id: \"marketplace\",\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 available: false,\n Screen: makeMarketplaceScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"marketplace\",\n hasTitleData(\n configSection<{ Enabled?: boolean | null }>(ctx.client, \"Marketplace\"),\n ),\n \"the marketplace is not set up on the title, or switched off\",\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"
66
+ },
67
+ {
68
+ "path": "module.meta.json",
69
+ "content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\n \"inBase\": true,\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"
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "id": "mini-game",
3
+ "meta": {
4
+ "name": "Mini-game",
5
+ "type": "game",
6
+ "engine": "dom",
7
+ "genre": "casual"
8
+ },
9
+ "catalog": {
10
+ "type": "template",
11
+ "summary": "The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.",
12
+ "description": "A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.",
13
+ "provides": [
14
+ "a small tap game launched from the lobby",
15
+ "score submitted to the leaderboard",
16
+ "rounds counted by quests, events and the season",
17
+ "placeholder game to replace with the real one"
18
+ ],
19
+ "tags": [
20
+ "starter",
21
+ "mini-game",
22
+ "casual",
23
+ "tap",
24
+ "leaderboard"
25
+ ],
26
+ "author": {
27
+ "name": "iDos Games",
28
+ "url": "https://idosgames.com"
29
+ },
30
+ "version": "0.1.0"
31
+ },
32
+ "dependencies": {
33
+ "@idosgames/core": "0.19.0",
34
+ "@idosgames/module-sdk": "0.6.0",
35
+ "@idosgames/react": "0.3.0",
36
+ "react": "19.2.7"
37
+ },
38
+ "contentHash": "26089593df1b939aee3317743caeda47f82971bfd85dbd73eb9e95a9bc5e3d5c",
39
+ "files": [
40
+ {
41
+ "path": "i18n.ts",
42
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the mini-game. The leaderboard's name is the publisher's word (title config).\n\nconst EN = {\n title: \"Star catch\",\n play: \"Play\",\n playAgain: \"Play again\",\n tapStars: \"Tap the stars!\",\n timeLeft: \"Time\",\n score: \"Score\",\n yourScore: \"Your score\",\n submitted: \"Submitted to the leaderboard\",\n seasonStatus: \"season status\",\n toTable: \"Leaderboard\",\n best: \"Tap as many stars as you can in 15 seconds. Gold stars are worth 3.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n title: \"Ловец звёзд\",\n play: \"Играть\",\n playAgain: \"Ещё раз\",\n tapStars: \"Ловите звёзды!\",\n timeLeft: \"Время\",\n score: \"Очки\",\n yourScore: \"Ваш результат\",\n submitted: \"Результат отправлен в таблицу\",\n seasonStatus: \"статуса сезона\",\n toTable: \"Таблица лидеров\",\n best: \"Поймайте как можно больше звёзд за 15 секунд. Золотая — 3 очка.\",\n});\n"
43
+ },
44
+ {
45
+ "path": "index.ts",
46
+ "content": "// @idosgames/mod-mini-game — \"Star catch\", the smallest complete game module: a 15-second tap round\n// launched from the lobby's Play button, with a leaderboard score.\n//\n// Primary export is the module manifest; the pieces are exported too, so a project can reuse the\n// round inside its own game after copying this into src/modules/.\n\nexport { miniGameModule } from \"./module\";\nexport { makeMiniGamePanel } from \"./MiniGamePanel\";\nexport { MiniGame } from \"./MiniGame\";\nexport * from \"./model\";\n"
47
+ },
48
+ {
49
+ "path": "MiniGame.tsx",
50
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useSound,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// \"Tap the stars\": a 15-second round that produces a score for the leaderboard. Deliberately tiny —\n// the placeholder a project replaces with its real game, keeping the same contract: play, then hand\n// the score to onFinish (the play screen submits it and reports the round to quests).\n\nconst ROUND_MS = 15_000;\nconst SPAWN_MS = 520;\nconst LIFE_MS = 1_150;\n\ninterface Star {\n id: number;\n x: number;\n y: number;\n golden: boolean;\n bornAt: number;\n}\n\nexport function MiniGame({\n onFinish,\n onCancel,\n height = 380,\n}: {\n onFinish: (score: number) => void;\n onCancel: () => void;\n height?: number;\n}): ReactNode {\n const play = useSound();\n const [stars, setStars] = useState<Star[]>([]);\n const [score, setScore] = useState(0);\n const [leftMs, setLeftMs] = useState(ROUND_MS);\n const [pops, setPops] = useState<\n Array<{ id: number; x: number; y: number; value: number }>\n >([]);\n const scoreRef = useRef(0);\n const nextId = useRef(1);\n const finished = useRef(false);\n // Latest callback without restarting the round when the parent re-renders.\n const finish = useRef(onFinish);\n finish.current = onFinish;\n\n useEffect(() => {\n const started = performance.now();\n const spawn = setInterval(() => {\n const now = performance.now();\n setStars((list) => [\n ...list.filter((s) => now - s.bornAt < LIFE_MS),\n {\n id: nextId.current++,\n x: 8 + Math.random() * 78,\n y: 10 + Math.random() * 72,\n golden: Math.random() < 0.15,\n bornAt: now,\n },\n ]);\n }, SPAWN_MS);\n const clock = setInterval(() => {\n const left = Math.max(0, ROUND_MS - (performance.now() - started));\n setLeftMs(left);\n if (left === 0 && !finished.current) {\n finished.current = true;\n clearInterval(spawn);\n clearInterval(clock);\n finish.current(scoreRef.current);\n }\n }, 100);\n return () => {\n clearInterval(spawn);\n clearInterval(clock);\n };\n }, []);\n\n const hit = (star: Star) => {\n const value = star.golden ? 3 : 1;\n play(star.golden ? \"coin\" : \"countTick\");\n scoreRef.current += value;\n setScore(scoreRef.current);\n setStars((list) => list.filter((s) => s.id !== star.id));\n const popID = nextId.current++;\n setPops((list) => [...list, { id: popID, x: star.x, y: star.y, value }]);\n setTimeout(\n () => setPops((list) => list.filter((p) => p.id !== popID)),\n 600,\n );\n };\n\n return (\n <div\n style={{\n ...panel,\n position: \"relative\",\n height: `min(${height}px, 70vh)`,\n minHeight: 300,\n overflow: \"hidden\",\n background:\n \"radial-gradient(circle at 50% 30%, #3b47b8 0%, #151a52 75%)\",\n touchAction: \"manipulation\",\n userSelect: \"none\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 10,\n left: 12,\n right: 12,\n display: \"flex\",\n justifyContent: \"space-between\",\n zIndex: 2,\n }}\n >\n <span style={{ ...outlined, fontSize: 18 }}>\n {t(\"score\")}: {score}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 18,\n color: leftMs < 4000 ? v.red : v.text,\n }}\n >\n {t(\"timeLeft\")}: {Math.ceil(leftMs / 1000)}\n </span>\n </div>\n <div\n style={{\n position: \"absolute\",\n top: 40,\n left: 0,\n right: 0,\n textAlign: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.textDim,\n opacity: score === 0 ? 1 : 0,\n transition: \"opacity .4s\",\n }}\n >\n {t(\"tapStars\")}\n </div>\n {stars.map((s) => (\n <button\n key={s.id}\n type=\"button\"\n aria-label=\"star\"\n onPointerDown={() => hit(s)}\n style={{\n position: \"absolute\",\n left: `${s.x}%`,\n top: `${s.y}%`,\n width: 56,\n height: 56,\n marginLeft: -28,\n marginTop: -28,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n animation: `idos-star ${v.normal} ease-out`,\n }}\n >\n <Icon\n glyph=\"star\"\n size={s.golden ? 56 : 44}\n color={s.golden ? \"#ffe066\" : \"#fff27a\"}\n />\n </button>\n ))}\n {pops.map((p) => (\n <span\n key={p.id}\n style={{\n position: \"absolute\",\n left: `${p.x}%`,\n top: `${p.y}%`,\n ...outlined,\n fontSize: 22,\n color: v.gold,\n pointerEvents: \"none\",\n animation: \"idos-rise-in .6s ease reverse\",\n }}\n >\n +{p.value}\n </span>\n ))}\n <div\n style={{\n position: \"absolute\",\n bottom: 10,\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={onCancel}>\n ✕\n </Button>\n </div>\n </div>\n );\n}\n"
51
+ },
52
+ {
53
+ "path": "MiniGamePanel.tsx",
54
+ "content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n <div style={{ maxWidth: 760, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
55
+ },
56
+ {
57
+ "path": "model.ts",
58
+ "content": "// Pure helpers of the mini-game — covered by model.test.ts.\n\n/** Metric the mini-game reports once per finished round. A quest counts it only if it asks for it. */\nexport const GAMES_PLAYED_METRIC = \"games_played\";\n\n/** Leaderboard the mini-game submits to when the title has it; otherwise the first enabled one. */\nexport const PREFERRED_LEADERBOARD = \"weekly_score\";\n\nexport interface LeaderboardSection {\n Definitions?: Record<\n string,\n {\n LeaderboardID?: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n ScoreSources?: unknown[] | null;\n } | null\n > | null;\n}\n\n/**\n * The leaderboard the mini-game plays for: the preferred id if enabled, else the first enabled one.\n * A board fed by its own sources (invites, purchases…) is not a score table — the server fills it\n * and refuses a submitted score, so the mini-game never picks it.\n */\nexport function pickLeaderboard(\n section: LeaderboardSection | undefined,\n): { LeaderboardID: string; DisplayName?: string | null } | null {\n const defs = Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (\n d,\n ): d is {\n LeaderboardID: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n } => d !== null && d.IsEnabled !== false,\n )\n .filter(\n (d) =>\n ((d as { ScoreSources?: unknown[] | null }).ScoreSources ?? [])\n .length === 0,\n );\n return (\n defs.find((d) => d.LeaderboardID === PREFERRED_LEADERBOARD) ??\n defs.sort((a, b) => a.LeaderboardID.localeCompare(b.LeaderboardID))[0] ??\n null\n );\n}\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n { MetricID?: string | null; Source?: string } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Whether some quest counts the mini-game's rounds — only then is the metric worth reporting. */\nexport function questsCountGames(section: QuestSection | undefined): boolean {\n return Object.values(section?.Quests ?? {}).some((q) =>\n Object.values(q?.Objectives ?? {}).some(\n (o) =>\n o?.MetricID === GAMES_PLAYED_METRIC &&\n (o.Source ?? \"ClientApi\") === \"ClientApi\",\n ),\n );\n}\n\n// ── A round feeds the title's events and seasons ─────────────────────────────────────────────\n// Found in the config, so the mini-game needs neither the events nor the season module installed.\n\n/** The CustomAction a round of the mini-game reports to timed events. */\nexport const PLAY_ROUND_ACTION = \"play_round\";\n/** Status a round grants to every season chain open to clients. */\nexport const ROUND_STATUS = 10;\n\nexport interface TimedEventSection {\n Definitions?: Record<\n string,\n {\n Content?: EventContentView | null;\n Events?: Array<{ Content?: EventContentView | null } | null> | null;\n } | null\n > | null;\n}\ntype EventContentView = {\n TokenSources?: Array<{\n SourceType?: string | null;\n Params?: Record<string, string> | null;\n } | null> | null;\n};\n\nfunction countsRounds(content: EventContentView | null | undefined): boolean {\n return (content?.TokenSources ?? []).some(\n (s) =>\n s?.SourceType === \"CustomAction\" &&\n s.Params?.ActionName === PLAY_ROUND_ACTION,\n );\n}\n\n/** Events (by id) whose token sources pay for a round — own content or any phase of a chain. */\nexport function eventsCountingRounds(\n section: TimedEventSection | undefined,\n): string[] {\n return Object.entries(section?.Definitions ?? {})\n .filter(\n ([, d]) =>\n d &&\n (countsRounds(d.Content) ||\n (d.Events ?? []).some((p) => countsRounds(p?.Content))),\n )\n .map(([id]) => id);\n}\n\nexport interface SeasonSection {\n Chains?: Record<\n string,\n { GrantTokensAccessMode?: string | null } | null\n > | null;\n}\n\n/**\n * Season chains the client may grant status to. Only an explicit \"Both\" / \"ClientOnly\" opens a\n * chain: the engine's default is ServerOnly, so a missing field means \"server only\" too.\n */\nexport function seasonChainsForRounds(\n section: SeasonSection | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(\n ([, c]) =>\n c?.GrantTokensAccessMode === \"Both\" ||\n c?.GrantTokensAccessMode === \"ClientOnly\",\n )\n .map(([id]) => id);\n}\n"
59
+ },
60
+ {
61
+ "path": "module.ts",
62
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeMiniGamePanel } from \"./MiniGamePanel\";\nimport { t } from \"./i18n\";\n\n// The smallest complete game module: a mode of its own (a route + one full-screen panel, no scene)\n// that the base's lobby offers as \"Play\". It is what a new title gets when no other game fits the idea\n// — the placeholder the real game replaces, with the same contract: register a route, draw the mode,\n// and leave the lobby, balances and wallet to the base.\nexport const miniGameModule: Module = defineModule({\n id: \"mini-game\",\n meta: {\n name: \"Mini-game\",\n type: \"game\",\n genre: \"casual\",\n engine: \"dom\",\n },\n setup(ctx) {\n ctx.registerRoute({ id: \"mini-game\", label: t(\"title\"), icon: \"⭐\" });\n ctx.registerPanel({\n id: \"round\",\n slot: \"overlay\",\n component: makeMiniGamePanel(ctx.features),\n });\n\n // The round is ordinary React in the DOM: an agent reads it and presses its buttons like a person.\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", mode: ctx.modes.current() }),\n describeActions: {},\n });\n },\n});\n"
63
+ },
64
+ {
65
+ "path": "module.meta.json",
66
+ "content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
67
+ }
68
+ ]
69
+ }
@@ -0,0 +1,71 @@
1
+ {
2
+ "id": "profile",
3
+ "meta": {
4
+ "name": "Profile",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.",
12
+ "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 tutorials 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 lobby's profile button opens it. No title settings needed.",
13
+ "provides": [
14
+ "player name and ID",
15
+ "rename popup",
16
+ "sound, volume and fewer animations",
17
+ "language picker",
18
+ "analytics consent",
19
+ "log out",
20
+ "account deletion with confirmation"
21
+ ],
22
+ "tags": [
23
+ "profile",
24
+ "settings",
25
+ "account",
26
+ "sound",
27
+ "language",
28
+ "privacy",
29
+ "ui",
30
+ "screen"
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.6.0",
41
+ "@idosgames/react": "0.3.0",
42
+ "react": "19.2.7"
43
+ },
44
+ "contentHash": "13df00ce0a01385a46bf73d7b50c6308526dbd3d5e1fb585e00b16df388d677d",
45
+ "files": [
46
+ {
47
+ "path": "i18n.ts",
48
+ "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"
49
+ },
50
+ {
51
+ "path": "index.ts",
52
+ "content": "// @idosgames/mod-profile — Profile for any game: name, ID, language, sound and motion settings, analytics consent, log out and account deletion.\n\nexport { profileModule } from \"./module\";\n"
53
+ },
54
+ {
55
+ "path": "model.ts",
56
+ "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"
57
+ },
58
+ {
59
+ "path": "module.ts",
60
+ "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 lobby of the base opens it from its profile\n// button (features.open(\"profile\")).\nexport const profileModule: Module = defineModule({\n id: \"profile\",\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"
61
+ },
62
+ {
63
+ "path": "ProfileScreen.tsx",
64
+ "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"
65
+ },
66
+ {
67
+ "path": "module.meta.json",
68
+ "content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\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 tutorials 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 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"
69
+ }
70
+ ]
71
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "id": "pvp",
3
+ "meta": {
4
+ "name": "Arena",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "inBase": true,
11
+ "summary": "PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.",
12
+ "description": "A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.",
13
+ "provides": [
14
+ "open duels to join",
15
+ "my duels with cancel",
16
+ "create a duel with an entry",
17
+ "zone battle strategy",
18
+ "fight result replayed round by round",
19
+ "demo duel rule"
20
+ ],
21
+ "tags": [
22
+ "pvp",
23
+ "duel",
24
+ "arena",
25
+ "battle",
26
+ "matchmaking",
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.6.0",
39
+ "@idosgames/react": "0.3.0",
40
+ "react": "19.2.7"
41
+ },
42
+ "contentHash": "57db9149531e3285b2a843ae6a4aa181be750a067e0e142a0c2792920d1f0a36",
43
+ "files": [
44
+ {
45
+ "path": "i18n.ts",
46
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Arena\",\n open: \"Open duels\",\n mine: \"My duels\",\n create: \"New duel\",\n strategy: \"Strategy\",\n noOpen: \"No open duels — create one!\",\n noMine: \"You have no duels yet.\",\n entry: \"Entry\",\n winnerTakes: \"Winner takes\",\n hero: \"Hero\",\n rule: \"Rule\",\n fight: \"Fight!\",\n cancel: \"Cancel duel\",\n cancelled: \"Cancelled\",\n waiting: \"Waiting for an opponent\",\n won: \"Victory!\",\n lost: \"Defeat\",\n draw: \"Draw — entries returned\",\n youWon: \"You won\",\n youLost: \"You lost\",\n rounds: \"Rounds\",\n attack: \"Attack\",\n defense: \"Defence\",\n round: \"Round\",\n save: \"Save\",\n saved: \"Strategy saved\",\n addRound: \"+ round\",\n head: \"Head\",\n torso: \"Body\",\n legs: \"Legs\",\n hHit: \"hit\",\n hCritical: \"critical!\",\n hBlock: \"blocked\",\n hDodge: \"dodged\",\n you: \"You\",\n foe: \"Foe\",\n hp: \"HP\",\n player: \"Player\",\n noHero: \"You need a hero to fight.\",\n toHeroes: \"Heroes\",\n yours: \"Your duel\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Арена\",\n open: \"Доступные\",\n mine: \"Мои дуэли\",\n create: \"Новая дуэль\",\n strategy: \"Стратегия\",\n noOpen: \"Открытых дуэлей нет — создайте свою!\",\n noMine: \"У вас пока нет дуэлей.\",\n entry: \"Взнос\",\n winnerTakes: \"Победитель получит\",\n hero: \"Герой\",\n rule: \"Правило\",\n fight: \"В бой!\",\n cancel: \"Отменить дуэль\",\n cancelled: \"Отменена\",\n waiting: \"Ждёт соперника\",\n won: \"Победа!\",\n lost: \"Поражение\",\n draw: \"Ничья — взносы вернулись\",\n youWon: \"Вы выиграли\",\n youLost: \"Вы проиграли\",\n rounds: \"Раунды\",\n attack: \"Удар\",\n defense: \"Защита\",\n round: \"Раунд\",\n save: \"Сохранить\",\n saved: \"Стратегия сохранена\",\n addRound: \"+ раунд\",\n head: \"Голова\",\n torso: \"Корпус\",\n legs: \"Ноги\",\n hHit: \"удар\",\n hCritical: \"крит!\",\n hBlock: \"блок\",\n hDodge: \"уворот\",\n you: \"Вы\",\n foe: \"Соперник\",\n hp: \"ОЗ\",\n player: \"Игрок\",\n noHero: \"Для боя нужен герой.\",\n toHeroes: \"Герои\",\n yours: \"Ваша дуэль\",\n});\n"
47
+ },
48
+ {
49
+ "path": "index.ts",
50
+ "content": "// @idosgames/mod-pvp — PvP duels for any game: open duels, your duels, creating a duel for coins, a battle strategy and the fight's result.\n\nexport { pvpModule } from \"./module\";\n"
51
+ },
52
+ {
53
+ "path": "model.ts",
54
+ "content": "// Pure helpers of the PvP screen (Unity Alikhan/PVP/Lobby): the title's duel rules and their entry\n// bounds, the strategy (attack / defence zone per round), the outcome of a fight and its rounds.\n// The server fights the battle — nothing here computes damage. Covered by model.test.ts.\n\nexport type Zone = \"Head\" | \"Torso\" | \"Legs\";\nexport const ZONES: Zone[] = [\"Head\", \"Torso\", \"Legs\"];\nexport type Step = { AttackTarget: Zone; DefenseTarget: Zone };\n\n/** The built-in rule every title has, used when the title configures none. */\nexport const BUILT_IN_RULE = \"InstantBattle1v1\";\n\nexport interface DuelRule {\n id: string;\n name: string;\n description: string | null;\n currency: string | null;\n min: number;\n max: number;\n burnRate: number;\n}\n\ntype RuleDef = {\n RuleID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Economy?: { BurnRate?: number | null } | null;\n Entry?: {\n Allowed?: Array<{\n Kind?: string | null;\n CurrencyID?: string | null;\n MinAmount?: number | null;\n MaxAmount?: number | null;\n } | null> | null;\n } | null;\n};\n\n/** The duel rules and their currency entry bounds (the first currency rule of the whitelist). */\nexport function duelRules(\n defs:\n | {\n InstantBattle?: {\n Rules?: Record<string, RuleDef | null> | null;\n } | null;\n }\n | null\n | undefined,\n fallbackCurrency: string | null,\n): DuelRule[] {\n const rules = Object.entries(defs?.InstantBattle?.Rules ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => {\n const allowed = (r!.Entry?.Allowed ?? []).find(\n (a) => a?.Kind === \"VirtualCurrency\",\n );\n return {\n id: r!.RuleID || id,\n name: r!.DisplayName || id,\n description: r!.Description ?? null,\n currency: allowed?.CurrencyID ?? fallbackCurrency,\n min: Math.max(1, Number(allowed?.MinAmount ?? 1)),\n max:\n Number(allowed?.MaxAmount ?? 0) > 0\n ? Number(allowed!.MaxAmount)\n : Number.MAX_SAFE_INTEGER,\n burnRate: Number(r!.Economy?.BurnRate ?? 0),\n } satisfies DuelRule;\n });\n return rules.length > 0\n ? rules\n : [\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: fallbackCurrency,\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ];\n}\n\n/** What the winner takes: both entries minus the burn. */\nexport function winnerTakes(entry: number, burnRate: number): number {\n return Math.floor(entry * 2 * (1 - Math.min(1, Math.max(0, burnRate))));\n}\n\nexport function clampEntry(value: number, rule: DuelRule): number {\n return Math.min(rule.max, Math.max(rule.min, Math.floor(value)));\n}\n\n/** The entry of a listed match: its first currency line. */\nexport function matchEntry(match: {\n Entry?: {\n Entries?: Array<{\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n}): { currency: string | null; amount: number } {\n const e = match.Entry?.Entries?.find((x) => x?.CurrencyID);\n return { currency: e?.CurrencyID ?? null, amount: Number(e?.Amount ?? 0) };\n}\n\n/** The saved strategy, or three sensible rounds to start from. */\nexport function strategyOrDefault(\n saved:\n | ReadonlyArray<{\n AttackTarget?: string | null;\n DefenseTarget?: string | null;\n }>\n | null\n | undefined,\n): Step[] {\n const steps = (saved ?? [])\n .map((s) => ({\n AttackTarget: asZone(s.AttackTarget),\n DefenseTarget: asZone(s.DefenseTarget),\n }))\n .slice(0, 10);\n return steps.length > 0\n ? steps\n : [\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Torso\", DefenseTarget: \"Head\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Torso\" },\n ];\n}\n\nfunction asZone(z: string | null | undefined): Zone {\n return z === \"Torso\" || z === \"Legs\" ? z : \"Head\";\n}\n\nexport type Outcome = \"win\" | \"lose\" | \"draw\";\n\nexport function outcomeFor(\n battle:\n | { WinnerUserID?: string | null; IsDraw?: boolean | null }\n | null\n | undefined,\n me: string | null | undefined,\n): Outcome {\n if (!battle || battle.IsDraw || !battle.WinnerUserID) return \"draw\";\n return battle.WinnerUserID === me ? \"win\" : \"lose\";\n}\n\nexport interface RoundLine {\n round: number;\n mine: boolean;\n zone: Zone;\n hit: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n damage: number;\n hpLeft: number;\n}\n\n/** The fight round by round, from my side: whose blow, where, how it landed, HP left. */\nexport function roundLines(\n log:\n | ReadonlyArray<{\n RoundIndex?: number | null;\n AttackerID?: string | null;\n AttackZone?: string | null;\n HitType?: string | null;\n DamageDealt?: number | null;\n DefenderHpRemaining?: number | null;\n }>\n | null\n | undefined,\n me: string | null | undefined,\n): RoundLine[] {\n return (log ?? []).map((e, i) => ({\n // The engine counts rounds from 1.\n round: Number(e.RoundIndex ?? i + 1),\n mine: e.AttackerID === me,\n zone: asZone(e.AttackZone),\n hit:\n e.HitType === \"Critical\" || e.HitType === \"Block\" || e.HitType === \"Dodge\"\n ? e.HitType\n : \"Hit\",\n damage: Math.round(Number(e.DamageDealt ?? 0)),\n hpLeft: Math.max(0, Math.round(Number(e.DefenderHpRemaining ?? 0))),\n }));\n}\n\n/** Whether the title has a duel rule — until it does, the lobby hides the Arena tab. */\nexport function hasTitleData(\n section:\n | { InstantBattle?: { Rules?: Record<string, unknown> | null } | null }\n | null\n | undefined,\n): boolean {\n return Object.keys(section?.InstantBattle?.Rules ?? {}).length > 0;\n}\n"
55
+ },
56
+ {
57
+ "path": "module.ts",
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpModule: Module = defineModule({\n id: \"pvp\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n available: false,\n Screen: makePvpScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"pvp\",\n hasTitleData(\n configSection<Parameters<typeof hasTitleData>[0]>(ctx.client, \"Match\"),\n ),\n \"no duel rules on the title (Match.InstantBattle.Rules)\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
59
+ },
60
+ {
61
+ "path": "PvpScreen.tsx",
62
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n InstantBattleResponse,\n MatchDefinitions,\n PvPMatch,\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 Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n canAfford,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n ZONES,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n type DuelRule,\n type Step,\n type Zone,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels (Unity Alikhan/PVP/Lobby): open duels to join and my duels as tabs. Creating a duel\n// (rule, entry, hero), the battle strategy (attack / defence zone per round) and the fight's result\n// round by round are popups. The server fights the battle instantly; the winner takes both entries\n// minus the burn, a draw returns them.\n\ntype Tab = \"open\" | \"mine\";\ntype Hero = { id: string; name: string; power: number };\n\nfunction useHeroes(): Hero[] | null {\n const client = useIDosGamesClient();\n const [heroes, setHeroes] = useState<Hero[] | null>(null);\n useEffect(() => {\n void client.character.getUserCharacters().then((r) => {\n const defs =\n (\n client.data.config.getSection(\"Character\") as\n | {\n Definitions?: Record<\n string,\n { Identity?: { DisplayName?: string | null } | null } | null\n >;\n }\n | undefined\n )?.Definitions ?? {};\n const owned = r.ok ? Object.values(r.data.Characters ?? {}) : [];\n setHeroes(\n owned\n .filter((c) => c?.CharacterID)\n .map((c) => ({\n id: c!.CharacterID!,\n name:\n defs[c!.CharacterID!]?.Identity?.DisplayName ?? c!.CharacterID!,\n power: Number(c!.Power ?? 0),\n }))\n .sort((a, b) => b.power - a.power),\n );\n });\n }, [client]);\n return heroes;\n}\n\nexport function makePvpScreen(features: FeatureRegistry) {\n return function PvpScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<MatchDefinitions | null | undefined>(\n undefined,\n );\n const [tab, setTab] = useState<Tab>(\"open\");\n const [creating, setCreating] = useState(false);\n const [editing, setEditing] = useState(false);\n const [joining, setJoining] = useState<PvPMatch | null>(null);\n const [result, setResult] = useState<InstantBattleResponse | null>(null);\n const [version, setVersion] = useState(0);\n const heroes = useHeroes();\n useEffect(() => {\n void client.match\n .getDefinitions()\n .then((r) => setDefs(r.ok ? r.data : null));\n }, [client]);\n const firstCurrency =\n Object.keys(\n (\n client.data.config.getSection(\"Currency\") as\n { VirtualCurrencies?: Record<string, unknown> } | undefined\n )?.VirtualCurrencies ?? {},\n )[0] ?? null;\n const rules = duelRules(defs as never, firstCurrency);\n const refresh = () => setVersion((n) => n + 1);\n\n if (defs === undefined || !heroes)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (heroes.length === 0)\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <EmptyState glyph=\"swords\" text={t(\"noHero\")} />\n {features.get(\"character\") ? (\n <Button tone=\"blue\" onClick={() => features.open(\"character\")}>\n {t(\"toHeroes\")}\n </Button>\n ) : null}\n </div>\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n <Button\n tone=\"gold\"\n attract\n onClick={() => setCreating(true)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"pvp:create\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setEditing(true)}>\n <Icon glyph=\"gear\" size={18} /> {t(\"strategy\")}\n </Button>\n </div>\n <Tabs\n tabs={[\n { id: \"open\", label: t(\"open\") },\n { id: \"mine\", label: t(\"mine\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div key={`${tab}:${version}`}>\n {tab === \"open\" ? (\n <OpenDuels rules={rules} onJoin={setJoining} />\n ) : (\n <MyDuels rules={rules} onChange={refresh} />\n )}\n </div>\n {creating ? (\n <CreatePopup\n rules={rules}\n heroes={heroes}\n onClose={() => setCreating(false)}\n onCreated={() => {\n setTab(\"mine\");\n refresh();\n }}\n />\n ) : null}\n {editing ? <StrategyPopup onClose={() => setEditing(false)} /> : null}\n {joining ? (\n <JoinPopup\n match={joining}\n rule={rules.find((r) => r.id === joining.RuleID) ?? rules[0]!}\n heroes={heroes}\n onClose={() => setJoining(null)}\n onFought={(res) => {\n setJoining(null);\n setResult(res);\n refresh();\n }}\n />\n ) : null}\n {result ? (\n <ResultPopup result={result} onClose={() => setResult(null)} />\n ) : null}\n </div>\n );\n };\n}\n\nfunction shortID(id: string | null | undefined): string {\n if (!id) return \"?\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\nfunction DuelRow({\n match,\n rules,\n index,\n right,\n onPick,\n}: {\n match: PvPMatch;\n rules: DuelRule[];\n index: number;\n right?: ReactNode;\n onPick?: () => void;\n}): ReactNode {\n const stagger = useStagger();\n const e = matchEntry(match);\n const rule = rules.find((r) => r.id === match.RuleID);\n return (\n <div\n onClick={onPick}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Icon glyph=\"swords\" size={30} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {rule?.name ?? match.RuleID} · {t(\"player\")}{\" \"}\n {shortID(match.CreatorID)}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"entry\")}{\" \"}\n {e.currency ? (\n <ResourceList\n lines={[{ kind: \"currency\", id: e.currency, amount: e.amount }]}\n size={16}\n />\n ) : null}\n {match.CreatorCharacterID ? ` · ${match.CreatorCharacterID}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction OpenDuels({\n rules,\n onJoin,\n}: {\n rules: DuelRule[];\n onJoin: (m: PvPMatch) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<PvPMatch[] | null>(null);\n useEffect(() => {\n void client.match\n .getAvailableMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noOpen\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n onPick={() => onJoin(m)}\n right={\n <Button size=\"sm\" tone=\"green\">\n {t(\"fight\")}\n </Button>\n }\n />\n ))}\n </div>\n );\n}\n\nfunction MyDuels({\n rules,\n onChange,\n}: {\n rules: DuelRule[];\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const me = client.auth.context?.userID;\n const [list, setList] = useState<PvPMatch[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.match\n .getMyMatches(0, 30)\n .then((r) => setList(r.ok ? (r.data.Matches ?? []) : []));\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"noMine\")} />;\n const cancel = async (id: string) => {\n setBusy(id);\n const res = await client.match.cancelMatch(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((m, i) => (\n <DuelRow\n key={m.MatchID}\n match={m}\n rules={rules}\n index={i}\n right={\n m.Status === \"Open\" && m.CreatorID === me ? (\n <div style={{ display: \"grid\", gap: 4, justifyItems: \"end\" }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"waiting\")}\n </span>\n <Button\n size=\"sm\"\n tone=\"grey\"\n busy={busy === m.MatchID}\n onClick={() => void cancel(m.MatchID)}\n >\n {t(\"cancel\")}\n </Button>\n </div>\n ) : m.Status === \"Cancelled\" ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"cancelled\")}\n </span>\n ) : m.Status === \"Completed\" ? (\n <span\n style={{\n ...outlined,\n fontSize: 13,\n color: !m.WinnerUserID\n ? v.textDim\n : m.WinnerUserID === me\n ? v.green\n : v.red,\n }}\n >\n {!m.WinnerUserID\n ? t(\"draw\")\n : m.WinnerUserID === me\n ? t(\"youWon\")\n : t(\"youLost\")}\n </span>\n ) : null\n }\n />\n ))}\n </div>\n );\n}\n\nfunction HeroPicker({\n heroes,\n value,\n onChange,\n}: {\n heroes: Hero[];\n value: string;\n onChange: (id: string) => void;\n}): ReactNode {\n // One hero: nothing to pick, but the player still sees who fights (tabs need two).\n if (heroes.length === 1)\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n <Icon glyph=\"hero\" size={22} /> {heroes[0]!.name} ·{\" \"}\n {formatAmount(heroes[0]!.power)}\n </div>\n );\n return (\n <Tabs\n tabs={heroes.map((h) => ({\n id: h.id,\n label: `${h.name} · ${formatAmount(h.power)}`,\n }))}\n value={value}\n onChange={onChange}\n />\n );\n}\n\nfunction CreatePopup({\n rules,\n heroes,\n onClose,\n onCreated,\n}: {\n rules: DuelRule[];\n heroes: Hero[];\n onClose: () => void;\n onCreated: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const [ruleID, setRuleID] = useState(rules[0]!.id);\n const rule = rules.find((r) => r.id === ruleID) ?? rules[0]!;\n const [amount, setAmount] = useState(clampEntry(50, rule));\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = rule.currency\n ? [{ kind: \"currency\" as const, id: rule.currency, amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const create = async () => {\n if (!rule.currency) return;\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.createMatch(\n {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: rule.currency,\n Amount: amount,\n },\n ],\n } as never,\n rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n onCreated();\n onClose();\n };\n return (\n <Popup title={t(\"create\")} onClose={onClose} width={440}>\n {rules.length > 1 ? (\n <Tabs\n tabs={rules.map((r) => ({ id: r.id, label: r.name }))}\n value={ruleID}\n onChange={(id) => {\n setRuleID(id);\n const r = rules.find((x) => x.id === id)!;\n setAmount(clampEntry(amount, r));\n }}\n />\n ) : null}\n {rule.description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {rule.description}\n </div>\n ) : null}\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <Field label={t(\"entry\")}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount - 10, rule))}\n >\n −\n </Button>\n <ResourceList lines={entryLine} size={26} dimIf={() => !affordable} />\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => setAmount(clampEntry(amount + 10, rule))}\n >\n +\n </Button>\n </div>\n </Field>\n {rule.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: rule.currency,\n amount: winnerTakes(amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy}\n onClick={() => void create()}\n >\n {t(\"create\")}\n </Button>\n </Popup>\n );\n}\n\nfunction JoinPopup({\n match,\n rule,\n heroes,\n onClose,\n onFought,\n}: {\n match: PvPMatch;\n rule: DuelRule;\n heroes: Hero[];\n onClose: () => void;\n onFought: (r: InstantBattleResponse) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const [hero, setHero] = useState(heroes[0]!.id);\n const [busy, setBusy] = useState(false);\n const e = matchEntry(match);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const entryLine = e.currency\n ? [{ kind: \"currency\" as const, id: e.currency, amount: e.amount }]\n : [];\n const affordable = canAfford(entryLine, balances);\n const fight = async () => {\n setBusy(true);\n const saved = client.data.user.state?.Match?.PvPBattleStrategy;\n const res = await client.match.instantBattle(\n match.MatchID,\n match.RuleID ?? rule.id,\n hero,\n saved ?? undefined,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n onFought(res.data);\n };\n return (\n <Popup\n title={`${rule.name} · ${t(\"player\")} ${shortID(match.CreatorID)}`}\n onClose={onClose}\n width={440}\n >\n <Field label={t(\"hero\")}>\n <HeroPicker heroes={heroes} value={hero} onChange={setHero} />\n </Field>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"entry\")}\n </div>\n <ResourceList lines={entryLine} size={28} dimIf={() => !affordable} />\n {e.currency ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {t(\"winnerTakes\")}{\" \"}\n <ResourceList\n lines={[\n {\n kind: \"currency\",\n id: e.currency,\n amount: winnerTakes(e.amount, rule.burnRate),\n },\n ]}\n size={18}\n />\n </div>\n ) : null}\n </div>\n <Button\n tone=\"green\"\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void fight()}\n data-tutorial-anchor=\"pvp:fight\"\n >\n <Icon glyph=\"swords\" size={18} /> {t(\"fight\")}\n </Button>\n </Popup>\n );\n}\n\nconst ZONE_LABEL: Record<Zone, Parameters<typeof t>[0]> = {\n Head: \"head\",\n Torso: \"torso\",\n Legs: \"legs\",\n};\n\nfunction StrategyPopup({ onClose }: { onClose: () => void }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [steps, setSteps] = useState<Step[]>(() =>\n strategyOrDefault(client.data.user.state?.Match?.PvPBattleStrategy),\n );\n const [busy, setBusy] = useState(false);\n const set = (i: number, key: keyof Step, zone: Zone) =>\n setSteps((s) => s.map((st, j) => (j === i ? { ...st, [key]: zone } : st)));\n const save = async () => {\n setBusy(true);\n const res = await client.match.saveStrategy(steps);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"saved\"));\n onClose();\n };\n const zoneTabs = ZONES.map((z) => ({ id: z, label: t(ZONE_LABEL[z]) }));\n return (\n <Popup title={t(\"strategy\")} onClose={onClose} width={460}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {steps.map((s, i) => (\n <div\n key={i}\n style={{ ...panel, padding: 10, display: \"grid\", gap: 6 }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span>\n {t(\"round\")} {i + 1}\n </span>\n {steps.length > 1 ? (\n <button\n type=\"button\"\n onClick={() => setSteps((st) => st.filter((_, j) => j !== i))}\n style={{\n background: \"none\",\n border: \"none\",\n color: v.textDim,\n cursor: \"pointer\",\n }}\n >\n ✕\n </button>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"attack\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.AttackTarget}\n onChange={(z) => set(i, \"AttackTarget\", z)}\n />\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"defense\")}\n </div>\n <Tabs\n tabs={zoneTabs}\n value={s.DefenseTarget}\n onChange={(z) => set(i, \"DefenseTarget\", z)}\n />\n </div>\n ))}\n {steps.length < 10 ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() =>\n setSteps((s) => [\n ...s,\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n ])\n }\n >\n {t(\"addRound\")}\n </Button>\n ) : null}\n <Button tone=\"green\" busy={busy} onClick={() => void save()}>\n {t(\"save\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nconst HIT_TEXT = {\n Hit: \"hHit\",\n Critical: \"hCritical\",\n Block: \"hBlock\",\n Dodge: \"hDodge\",\n} as const;\n\nfunction ResultPopup({\n result,\n onClose,\n}: {\n result: InstantBattleResponse;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const me = client.auth.context?.userID;\n const outcome = outcomeFor(result.Battle, me);\n const rounds = roundLines(result.Battle?.BattleLog, me);\n const stagger = useStagger();\n const [shown, setShown] = useState(0);\n const reward =\n result.Battle?.NetReward?.Entries?.filter((e) => e?.CurrencyID).map(\n (e) => ({\n kind: \"currency\" as const,\n id: e!.CurrencyID!,\n amount: Number(e!.Amount ?? 0),\n }),\n ) ?? [];\n useEffect(() => {\n kit.play(\n outcome === \"win\" ? \"levelUp\" : outcome === \"lose\" ? \"error\" : \"notify\",\n );\n }, []);\n // Rounds appear one by one — a short replay of the fight.\n const next = useCallback(\n () => setShown((n) => Math.min(rounds.length, n + 1)),\n [rounds.length],\n );\n useEffect(() => {\n if (shown >= rounds.length) return;\n const id = setTimeout(next, 220);\n return () => clearTimeout(id);\n }, [shown, next, rounds.length]);\n return (\n <Popup\n title={\n outcome === \"win\"\n ? t(\"won\")\n : outcome === \"lose\"\n ? t(\"lost\")\n : t(\"draw\")\n }\n onClose={onClose}\n width={460}\n variant=\"center\"\n >\n <div\n className=\"idos-bounce\"\n style={{ display: \"grid\", justifyItems: \"center\" }}\n >\n <Icon glyph={outcome === \"win\" ? \"trophy\" : \"swords\"} size={64} />\n </div>\n {outcome === \"win\" && reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <ResourceList lines={reward} size={30} />\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"rounds\")}\n </div>\n <div\n className=\"idos-scroll\"\n style={{ display: \"grid\", gap: 4, maxHeight: 260, overflowY: \"auto\" }}\n >\n {rounds.slice(0, shown).map((r, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: \"6px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n fontSize: 13,\n borderColor: r.mine ? v.green : v.red,\n ...stagger(0),\n }}\n >\n <span style={{ ...outlined, minWidth: 28 }}>{r.round}</span>\n <span style={{ ...outlined, color: r.mine ? v.green : v.red }}>\n {r.mine ? t(\"you\") : t(\"foe\")}\n </span>\n <span style={{ flex: 1 }}>\n → {t(ZONE_LABEL[r.zone])}, {t(HIT_TEXT[r.hit])}\n </span>\n <span style={{ ...outlined }}>−{r.damage}</span>\n <span\n style={{ color: v.textDim, minWidth: 54, textAlign: \"right\" }}\n >\n {r.hpLeft} {t(\"hp\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={onClose}>\n OK\n </Button>\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"
63
+ },
64
+ {
65
+ "path": "module.meta.json",
66
+ "content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
67
+ }
68
+ ]
69
+ }