@idosgames/mcp 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +69 -25
- package/registry/index.json +360 -412
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/{character-ui.json → character.json} +8 -11
- package/registry/modules/chat.json +71 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -11
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -10
- package/registry/modules/{events-ui.json → events.json} +8 -11
- package/registry/modules/{friends-ui.json → friends.json} +8 -11
- package/registry/modules/idle-rpg.json +6 -6
- package/registry/modules/{inventory-ui.json → inventory.json} +9 -12
- package/registry/modules/{referral-ui.json → invite.json} +8 -11
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -11
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/{profile-ui.json → profile.json} +8 -11
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -12
- package/registry/modules/{rewards-ui.json → rewards.json} +8 -11
- package/registry/modules/{season-ui.json → season.json} +8 -11
- package/registry/modules/{store-ui.json → store.json} +9 -12
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -11
- package/registry/modules/voxelcraft.json +2 -446
- package/registry/modules/workshop.json +11 -6
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/marketplace-ui.json +0 -75
- package/registry/modules/showcase.json +0 -92
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "marketplace-ui",
|
|
3
|
-
"meta": {
|
|
4
|
-
"name": "Marketplace",
|
|
5
|
-
"type": "app",
|
|
6
|
-
"engine": "dom"
|
|
7
|
-
},
|
|
8
|
-
"catalog": {
|
|
9
|
-
"type": "feature",
|
|
10
|
-
"summary": "Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.",
|
|
11
|
-
"description": "A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.",
|
|
12
|
-
"provides": [
|
|
13
|
-
"player marketplace screen",
|
|
14
|
-
"listings and auctions",
|
|
15
|
-
"sell popup with price and duration",
|
|
16
|
-
"bids with the minimum next bid",
|
|
17
|
-
"direct trades",
|
|
18
|
-
"claims and history",
|
|
19
|
-
"marketplace badge",
|
|
20
|
-
"demo marketplace rules"
|
|
21
|
-
],
|
|
22
|
-
"tags": [
|
|
23
|
-
"marketplace",
|
|
24
|
-
"trading",
|
|
25
|
-
"auction",
|
|
26
|
-
"listing",
|
|
27
|
-
"p2p",
|
|
28
|
-
"ui",
|
|
29
|
-
"screen",
|
|
30
|
-
"economy"
|
|
31
|
-
],
|
|
32
|
-
"author": {
|
|
33
|
-
"name": "iDos Games",
|
|
34
|
-
"url": "https://idosgames.com"
|
|
35
|
-
},
|
|
36
|
-
"version": "0.1.0"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.19.0",
|
|
40
|
-
"@idosgames/module-sdk": "0.5.0",
|
|
41
|
-
"@idosgames/react": "0.3.0",
|
|
42
|
-
"react": "19.2.7"
|
|
43
|
-
},
|
|
44
|
-
"contentHash": "991caf4844f3f3e56e846769c94999c10056178016c2c3c74ca0dd6109be6e7d",
|
|
45
|
-
"files": [
|
|
46
|
-
{
|
|
47
|
-
"path": "i18n.ts",
|
|
48
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Market\",\n market: \"Market\",\n myLots: \"My lots\",\n myBids: \"My bids\",\n trades: \"Trades\",\n claims: \"To claim\",\n history: \"History\",\n sell: \"Sell\",\n lots: \"lot|lots\",\n auctions: \"auction|auctions\",\n buy: \"Buy\",\n bid: \"Bid\",\n yourBid: \"Your bid\",\n currentBid: \"Current bid\",\n startingBid: \"Starting bid\",\n leading: \"You lead\",\n endsIn: \"Ends in\",\n listing: \"Fixed price\",\n auction: \"Auction\",\n price: \"Price\",\n amount: \"Amount\",\n duration: \"Duration\",\n hours: \"h\",\n commission: \"Commission\",\n create: \"Put up for sale\",\n cancel: \"Take back\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n accept: \"Accept\",\n decline: \"Decline\",\n gift: \"Gift\",\n wants: \"Wants\",\n from: \"From\",\n pickItem: \"Pick an item\",\n nothingToSell: \"Nothing to sell — tradable items you own appear here.\",\n noTradableCurrency:\n \"No currency of this game can be traded between players yet.\",\n empty: \"Nothing here yet.\",\n closed: \"The market is closed right now.\",\n disabled: \"No marketplace in this game. Enable it in LiveOps → Marketplace.\",\n sold: \"Sold\",\n bought: \"Bought\",\n seller: \"Seller\",\n you: \"You\",\n auctionRuns: \"The auction runs to its end — come back to claim the proceeds.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Рынок\",\n market: \"Рынок\",\n myLots: \"Мои лоты\",\n myBids: \"Мои ставки\",\n trades: \"Обмены\",\n claims: \"К получению\",\n history: \"История\",\n sell: \"Продать\",\n lots: \"лот|лота|лотов\",\n auctions: \"аукцион|аукциона|аукционов\",\n buy: \"Купить\",\n bid: \"Ставка\",\n yourBid: \"Ваша ставка\",\n currentBid: \"Текущая ставка\",\n startingBid: \"Начальная ставка\",\n leading: \"Вы лидируете\",\n endsIn: \"До конца\",\n listing: \"Фиксированная цена\",\n auction: \"Аукцион\",\n price: \"Цена\",\n amount: \"Количество\",\n duration: \"Срок\",\n hours: \"ч\",\n commission: \"Комиссия\",\n create: \"Выставить\",\n cancel: \"Снять\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n gift: \"Подарок\",\n wants: \"Просит\",\n from: \"От\",\n pickItem: \"Выберите предмет\",\n nothingToSell:\n \"Продавать нечего — здесь появятся ваши предметы, которыми можно торговать.\",\n noTradableCurrency:\n \"В этой игре пока нет валюты, которой игроки могут торговать между собой.\",\n empty: \"Пока пусто.\",\n closed: \"Рынок сейчас закрыт.\",\n disabled: \"Рынка в этой игре нет. Включите его в LiveOps → Marketplace.\",\n sold: \"Продано\",\n bought: \"Куплено\",\n seller: \"Продавец\",\n you: \"Вы\",\n auctionRuns: \"Аукцион идёт до конца — возвращайтесь забрать выручку.\",\n});\n"
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
"path": "index.ts",
|
|
52
|
-
"content": "// @idosgames/mod-marketplace-ui — Marketplace screen for any game: browse lots by item, buy and bid, sell your items as listings or auctions, direct trades, claims and history.\n\nexport { marketplaceUiModule } from \"./module\";\n"
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"path": "MarketplaceScreen.tsx",
|
|
56
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace\n .getDefinitions()\n .then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.user.getUserInventory();\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyLeadingBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(0,0,0,.3)\",\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
"path": "model.test.ts",
|
|
60
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n auctionDurations,\n commissionPercent,\n groupCounts,\n listingDurations,\n marketplaceBadge,\n minNextBid,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n} from \"./model\";\n\nconst defs = (seed as unknown as { Marketplace: never }).Marketplace;\nconst coins = (amount: number) => ({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: amount }],\n});\n\ndescribe(\"marketplace\", () => {\n it(\"reads the seeded rules: 5% commission (a fraction in config), listing and auction durations\", () => {\n expect(commissionPercent(defs)).toBe(5);\n expect(listingDurations(defs)).toEqual([24, 72]);\n expect(auctionDurations(defs)).toEqual([1, 24, 72]);\n });\n\n it(\"next bid follows the engine: start price first, then current + max(ceil(5%), absolute)\", () => {\n expect(minNextBid({ Auction: { StartingBid: 40 } } as never, defs)).toBe(\n 40,\n );\n expect(\n minNextBid(\n {\n Auction: { StartingBid: 40, CurrentBid: 40, CurrentBidderID: \"u\" },\n } as never,\n defs,\n ),\n ).toBe(42);\n expect(\n minNextBid(\n { Auction: { CurrentBid: 10, CurrentBidderID: \"u\" } } as never,\n defs,\n ),\n ).toBe(11);\n });\n\n it(\"lists cheapest listings first, then auctions ending soonest\", () => {\n const sorted = sortOffers([\n { OfferID: \"a2\", OfferType: \"Auction\", ExpiresAt: \"2026-10-02\" },\n { OfferID: \"l50\", OfferType: \"Listing\", Price: coins(50) },\n { OfferID: \"a1\", OfferType: \"Auction\", ExpiresAt: \"2026-10-01\" },\n { OfferID: \"l20\", OfferType: \"Listing\", Price: coins(20) },\n ] as never);\n expect(sorted.map((o) => o.OfferID)).toEqual([\"l20\", \"l50\", \"a1\", \"a2\"]);\n });\n\n it(\"sells only tradable items and never a worn one\", () => {\n const items = new Map(\n Object.entries({\n potion: { IsStackable: true, IsTradable: true, CatalogID: \"Item\" },\n soulbound: { IsStackable: true, IsTradable: false },\n sword: { IsStackable: false, IsTradable: true, CatalogID: \"Item\" },\n }),\n );\n const out = sellableItems(\n {\n Items: {\n potion: { StackableAmount: 3 },\n soulbound: { StackableAmount: 9 },\n sword: { TotalAmount: 2 },\n },\n UnstackableItems: {\n s1: { ItemID: \"sword\", EquippedSlot: \"Weapon\" },\n s2: { ItemID: \"sword\" },\n },\n },\n items,\n );\n expect(out).toEqual([\n { itemID: \"potion\", catalogID: \"Item\", amount: 3, instances: [] },\n { itemID: \"sword\", catalogID: \"Item\", amount: 1, instances: [\"s2\"] },\n ]);\n });\n\n it(\"prices only in currencies tradable between players\", () => {\n expect(\n tradableCurrencies({\n CO: { Permissions: { IsTradable: true } },\n GE: { Permissions: {} },\n XX: null,\n }),\n ).toEqual([\"CO\"]);\n expect(priceBundle(\"CO\", 0.4)).toEqual({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 1 }],\n });\n });\n\n it(\"group counts come from CountsByType when the mirrors are absent\", () => {\n expect(groupCounts({ CountsByType: { Listing: 2, Auction: 0 } })).toEqual({\n listings: 2,\n auctions: 0,\n });\n expect(groupCounts({ ListingCount: 1, AuctionCount: 3 })).toEqual({\n listings: 1,\n auctions: 3,\n });\n });\n\n it(\"the badge counts things to claim and trades waiting for an answer\", () => {\n expect(marketplaceBadge({ Claimables: [1, 2], IncomingTrades: [1] })).toBe(\n 3,\n );\n expect(marketplaceBadge(null)).toBe(0);\n });\n});\n"
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
"path": "model.ts",
|
|
64
|
-
"content": "// Pure helpers of the marketplace screen (Unity Alikhan/Marketplace) — covered by model.test.ts.\n// The server holds the escrow and settles every trade; these only decide what the screen shows.\n\nimport type {\n MarketplaceDefinitions,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport {\n bundleLines,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The price of a listing, as lines. */\nexport function offerPrice(offer: MarketplaceOfferView): ResourceLine[] {\n return bundleLines(offer.Price as never);\n}\n\n/** The single currency of a price (listings are priced in one currency in this screen). */\nexport function priceAmount(offer: MarketplaceOfferView): number {\n return offerPrice(offer)[0]?.amount ?? 0;\n}\n\n/** Offers for one item, cheapest listing first, then auctions ending soonest. */\nexport function sortOffers(\n offers: MarketplaceOfferView[],\n): MarketplaceOfferView[] {\n const listings = offers\n .filter((o) => o.OfferType === \"Listing\")\n .sort((a, b) => priceAmount(a) - priceAmount(b));\n const auctions = offers\n .filter((o) => o.OfferType === \"Auction\")\n .sort((a, b) =>\n String(a.ExpiresAt ?? \"\").localeCompare(String(b.ExpiresAt ?? \"\")),\n );\n return [\n ...listings,\n ...auctions,\n ...offers.filter(\n (o) => o.OfferType !== \"Listing\" && o.OfferType !== \"Auction\",\n ),\n ];\n}\n\n/** The lowest bid the server accepts next: the current bid plus the configured step (or the start). */\nexport function minNextBid(\n offer: MarketplaceOfferView,\n defs: MarketplaceDefinitions | null | undefined,\n): number {\n const a = offer.Auction;\n const current = Number(a?.CurrentBid ?? 0);\n if (!a || current <= 0 || !a.CurrentBidderID)\n return Math.max(1, Number(a?.StartingBid ?? 1));\n // The engine's rule: step = max(ceil(current × fraction), MinBidStepAbsolute); 0.05 = 5%.\n const fraction = Number(defs?.Auctions?.MinBidStepPercent ?? 0.05);\n const abs = Number(defs?.Auctions?.MinBidStepAbsolute ?? 0);\n const step = Math.max(1, abs, Math.ceil(current * fraction));\n return current + step;\n}\n\n/** Durations the title allows for a listing (hours), with a sane fallback. */\nexport function listingDurations(\n defs: MarketplaceDefinitions | null | undefined,\n): number[] {\n const allowed = (defs?.Listings?.AllowedDurationsHours ?? []).filter(\n (h) => h > 0,\n );\n return allowed.length > 0 ? [...allowed].sort((a, b) => a - b) : [24, 72];\n}\n\n/** Durations for an auction within the title's bounds. */\nexport function auctionDurations(\n defs: MarketplaceDefinitions | null | undefined,\n): number[] {\n const min = Math.max(1, Number(defs?.Auctions?.MinDurationHours ?? 1));\n const max = Math.max(min, Number(defs?.Auctions?.MaxDurationHours ?? 72));\n return [...new Set([min, Math.min(max, 24), max])]\n .filter((h) => h >= min && h <= max)\n .sort((a, b) => a - b);\n}\n\n/** Commission the seller pays, in percent for display. The config holds a FRACTION (0.05 = 5%). */\nexport function commissionPercent(\n defs: MarketplaceDefinitions | null | undefined,\n): number {\n return Math.round(Number(defs?.Commission?.Percent ?? 0.05) * 1000) / 10;\n}\n\nexport interface SellableItem {\n itemID: string;\n catalogID: string;\n amount: number;\n /** Unstackable: instance ids the player can put up (not equipped). */\n instances: string[];\n}\n\n/** Owned items the player may sell: tradable definitions, instances that are not worn. */\nexport function sellableItems(\n inventory:\n | {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; EquippedSlot?: unknown } | null\n > | null;\n }\n | null\n | undefined,\n items: Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >,\n): SellableItem[] {\n const out = new Map<string, SellableItem>();\n for (const [id, t] of Object.entries(inventory?.Items ?? {})) {\n const def = items.get(id);\n if (!def || def.IsTradable === false || def.IsStackable === false) continue;\n const amount = Number(t?.StackableAmount ?? t?.TotalAmount ?? 0);\n if (amount > 0)\n out.set(id, {\n itemID: id,\n catalogID: def.CatalogID ?? \"Item\",\n amount,\n instances: [],\n });\n }\n for (const [instanceID, inst] of Object.entries(\n inventory?.UnstackableItems ?? {},\n )) {\n if (!inst?.ItemID || inst.EquippedSlot) continue;\n const def = items.get(inst.ItemID);\n if (!def || def.IsTradable === false) continue;\n const entry = out.get(inst.ItemID) ?? {\n itemID: inst.ItemID,\n catalogID: def.CatalogID ?? \"Item\",\n amount: 0,\n instances: [],\n };\n entry.instances.push(instanceID);\n entry.amount = entry.instances.length;\n out.set(inst.ItemID, entry);\n }\n return [...out.values()].sort((a, b) => a.itemID.localeCompare(b.itemID));\n}\n\n/** Listings and auctions of a market group. The server fills CountsByType (the *Count fields are\n * optional mirrors of it), so both are read. */\nexport function groupCounts(g: {\n CountsByType?: Partial<Record<string, number>> | null;\n ListingCount?: number | null;\n AuctionCount?: number | null;\n}): { listings: number; auctions: number } {\n return {\n listings: Number(g.ListingCount ?? g.CountsByType?.Listing ?? 0),\n auctions: Number(g.AuctionCount ?? g.CountsByType?.Auction ?? 0),\n };\n}\n\n/** Currencies a lot can be priced in: the engine refuses one not tradable between players. */\nexport function tradableCurrencies(\n currencies: Record<\n string,\n { Permissions?: { IsTradable?: boolean | null } | null } | null | undefined\n >,\n): string[] {\n return Object.entries(currencies)\n .filter(([, c]) => c?.Permissions?.IsTradable === true)\n .map(([id]) => id);\n}\n\n/** A price bundle of one currency. */\nexport function priceBundle(\n currencyID: string,\n amount: number,\n): {\n Entries: Array<{\n Type: \"VirtualCurrency\";\n CurrencyID: string;\n Amount: number;\n }>;\n} {\n return {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: currencyID,\n Amount: Math.max(1, Math.floor(amount)),\n },\n ],\n };\n}\n\n/** Badge of the marketplace: things to claim + incoming trades waiting for an answer. */\nexport function marketplaceBadge(\n state:\n | { Claimables?: unknown[] | null; IncomingTrades?: unknown[] | null }\n | null\n | undefined,\n): number {\n return (\n (state?.Claimables?.length ?? 0) + (state?.IncomingTrades?.length ?? 0)\n );\n}\n"
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
"path": "module.ts",
|
|
68
|
-
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMarketplaceScreen } from \"./MarketplaceScreen\";\nimport { marketplaceBadge } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace as a feature of the game. Badge: things to claim (won lots, expired escrow) and\n// incoming trades waiting for an answer — read once at start and after every marketplace action.\nexport const marketplaceUiModule: Module = defineModule({\n id: \"marketplace-ui\",\n meta: { name: \"Marketplace\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"marketplace\",\n label: t(\"title\"),\n icon: \"market\",\n group: \"economy\",\n order: 70,\n Screen: makeMarketplaceScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"marketplace\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function MarketplaceBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n // A title without the section has no marketplace — no request at all.\n if (!configSection(client, \"Marketplace\")) return;\n const check = () =>\n void client.marketplace\n .getMyState()\n .then(\n (res) =>\n res.ok &&\n features.setBadge(\"marketplace\", marketplaceBadge(res.data)),\n );\n check();\n const id = setInterval(check, 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
"path": "module.meta.json",
|
|
72
|
-
"content": "{\n \"id\": \"marketplace-ui\",\n \"type\": \"feature\",\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
73
|
-
}
|
|
74
|
-
]
|
|
75
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "showcase",
|
|
3
|
-
"meta": {
|
|
4
|
-
"name": "Showcase",
|
|
5
|
-
"type": "game",
|
|
6
|
-
"engine": "dom",
|
|
7
|
-
"genre": "casual"
|
|
8
|
-
},
|
|
9
|
-
"sharedUi": {
|
|
10
|
-
"drawsOwn": [
|
|
11
|
-
"account",
|
|
12
|
-
"menu"
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
|
-
"catalog": {
|
|
16
|
-
"type": "template",
|
|
17
|
-
"summary": "The lobby a new title starts from: balances, the profile and the game's screens (shop, heroes, leaders, rewards, …) as tabs and 'More', plus a tiny mini-game.",
|
|
18
|
-
"description": "The minimal complete app a new title starts from when no other template fits the idea. It is a LOBBY: every screen is another catalog module registered as a feature of the game (ctx.features) — store-ui, inventory-ui, leaderboard-ui, rewards-ui and the other *-ui modules — and the lobby lays out whatever is installed: tabs at the bottom with 'More' on a phone, a centred tab pill on a tablet, a side rail on a desktop, badges on every tab. Its own content is only a 15-second tap mini-game that submits a score to the title's leaderboard and reports rounds to quests. The header shows the balances (reward icons fly into them) and the player's profile. Remove a screen module from src/modules.ts and its tab disappears; add one and it appears. Ships title-seed.json with the currencies and items the screens share; each screen module brings its own section.",
|
|
19
|
-
"provides": [
|
|
20
|
-
"lobby for a game made of screen modules",
|
|
21
|
-
"bottom tabs with More on phones, tab pill on tablets, side rail on desktops",
|
|
22
|
-
"badges on tabs",
|
|
23
|
-
"balance counters that reward icons fly into",
|
|
24
|
-
"player profile button",
|
|
25
|
-
"tap mini-game that submits a leaderboard score",
|
|
26
|
-
"demo currencies and items"
|
|
27
|
-
],
|
|
28
|
-
"tags": [
|
|
29
|
-
"starter",
|
|
30
|
-
"showcase",
|
|
31
|
-
"lobby",
|
|
32
|
-
"menu",
|
|
33
|
-
"tabs",
|
|
34
|
-
"mini-game",
|
|
35
|
-
"casual"
|
|
36
|
-
],
|
|
37
|
-
"author": {
|
|
38
|
-
"name": "iDos Games",
|
|
39
|
-
"url": "https://idosgames.com"
|
|
40
|
-
},
|
|
41
|
-
"version": "0.1.0"
|
|
42
|
-
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@idosgames/core": "0.19.0",
|
|
45
|
-
"@idosgames/module-sdk": "0.5.0",
|
|
46
|
-
"@idosgames/react": "0.3.0",
|
|
47
|
-
"react": "19.2.7"
|
|
48
|
-
},
|
|
49
|
-
"contentHash": "72b9c79dc24f916da62550f6e69e37fc5d72e92a00db853f6f0ec6332b97372d",
|
|
50
|
-
"files": [
|
|
51
|
-
{
|
|
52
|
-
"path": "Account.tsx",
|
|
53
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n Popup,\n outlined,\n useGameLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The player's corner of the lobby header. With a profile screen installed (profile-ui) the button\n// opens it; otherwise a small popup with the player ID (tap to copy) and \"Log out\". The module\n// declares `sharedUi.drawsOwn: [\"account\"]`, so the host does not float its own copy over the tabs.\n\nexport function Account({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n const [copied, setCopied] = useState(false);\n const userID = client.auth.context?.userID ?? null;\n const hasProfile = features.get(\"profile\") !== undefined;\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n const logout = (): void => void client.auth.logout();\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n const onProfile = (): void => {\n kit.play(\"click\");\n if (hasProfile) features.open(\"profile\");\n else setOpen(true);\n };\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"profile\")}\n onClick={onProfile}\n style={chip}\n data-tutorial-anchor=\"lobby:profile\"\n >\n <Icon glyph=\"user\" size={layout === \"phone\" ? 30 : 24} />\n {layout !== \"phone\" && shortID ? (\n <span\n style={{\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 13,\n }}\n >\n {shortID}\n </span>\n ) : null}\n </button>\n {open ? (\n <Popup title={t(\"profile\")} onClose={() => setOpen(false)} width={380}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Icon glyph=\"user\" size={64} />\n {userID ? (\n <>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"playerId\")}\n </div>\n <div\n style={{\n ...outlined,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 14,\n wordBreak: \"break-all\",\n textAlign: \"center\",\n }}\n >\n {userID}\n </div>\n </>\n ) : null}\n </div>\n {userID ? (\n <Button tone=\"blue\" onClick={copy}>\n <Icon glyph={copied ? \"check\" : \"copy\"} size={20} />{\" \"}\n {copied ? t(\"copied\") : t(\"copyId\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={logout}>\n <Icon glyph=\"logout\" size={20} /> {t(\"logout\")}\n </Button>\n </Popup>\n ) : null}\n </>\n );\n}\n\nconst chip: CSSProperties = {\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: 3,\n paddingRight: 10,\n borderRadius: 999,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(8, 10, 40, .6)\",\n boxShadow: \"inset 0 2px 4px rgba(0,0,0,.4)\",\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n};\n"
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
"path": "i18n.ts",
|
|
57
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the lobby and its mini-game. Names of currencies, items and the rest are the\n// publisher's words (title config) and the features' own labels.\n\nconst EN = {\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 more: \"More\",\n profile: \"Profile\",\n playerId: \"Player ID\",\n copyId: \"Copy ID\",\n copied: \"Copied\",\n logout: \"Log out\",\n noFeatures:\n \"No screens installed. Add screen modules — store-ui, inventory-ui, leaderboard-ui, rewards-ui and others — to src/modules.ts.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n play: \"Играть\",\n playAgain: \"Ещё раз\",\n tapStars: \"Ловите звёзды!\",\n timeLeft: \"Время\",\n score: \"Очки\",\n yourScore: \"Ваш результат\",\n submitted: \"Результат отправлен в таблицу\",\n seasonStatus: \"статуса сезона\",\n toTable: \"Таблица лидеров\",\n best: \"Поймайте как можно больше звёзд за 15 секунд. Золотая — 3 очка.\",\n more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n noFeatures:\n \"Экраны не установлены. Добавьте модули-экраны — store-ui, inventory-ui, leaderboard-ui, rewards-ui и другие — в src/modules.ts.\",\n});\n"
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
"path": "index.ts",
|
|
61
|
-
"content": "// @idosgames/mod-showcase — the lobby a new title starts from: it lays out the screens of the other\n// installed modules (ctx.features) as tabs and \"More\", plus a tiny mini-game for the leaderboard.\n//\n// Primary export is the module manifest; the pieces are exported too, so a project can recompose\n// them (replace the mini-game with the real game) after copying this into src/modules/.\n\nexport { showcaseModule } from \"./module\";\nexport { makeLobbyPanel, type LobbyOptions } from \"./Lobby\";\nexport { makePlayScreen } from \"./PlayScreen\";\nexport { MiniGame } from \"./MiniGame\";\nexport * from \"./model\";\n"
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
"path": "Lobby.tsx",
|
|
65
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n ResourceCounter,\n outlined,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport { Account } from \"./Account\";\nimport { MORE, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby: the game's screens are OTHER modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace…); the lobby only lays them out, in three layouts by its width:\n// - phone: balances and the profile on top, the screen, 4 tabs + \"More\" at the bottom (the Unity lobby);\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. Remove a screen module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** false when another module (game-hud) already draws the balances for every mode. */\n drawBalances: boolean;\n /** false when another module (game-hud) already draws the player ID and \"Log out\". */\n drawAccount: boolean;\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const list = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(null);\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {options.drawBalances\n ? balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n features.get(\"store\") ? () => go(\"store\") : undefined\n }\n />\n ))\n : null}\n </div>\n {options.drawAccount ? <Account features={features} /> : null}\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? \"0 3px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n color: active ? v.text : v.textDim,\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.04)\",\n boxShadow: active\n ? \"0 4px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n color: active ? v.text : v.textDim,\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
"path": "MiniGame.tsx",
|
|
69
|
-
"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"
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"path": "model.test.ts",
|
|
73
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport {\n PLAY_ROUND_ACTION,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n splitTabs,\n tabSlots,\n type LobbyFeature,\n} from \"./model\";\n\n// The lobby decides which of the game's features become tabs and which go under \"More\" — by width,\n// with no knowledge of what the features are. The mini-game helpers pick what it plays for.\n\nconst f = (\n id: string,\n primary: boolean,\n order: number,\n group = \"economy\",\n): LobbyFeature => ({ id, label: id, primary, order, group });\n\nconst MAIN = [\n f(\"store\", true, 10),\n f(\"rewards\", true, 40, \"events\"),\n f(\"leaderboard\", true, 30, \"events\"),\n f(\"inventory\", true, 50),\n f(\"character\", true, 20),\n];\nconst SECONDARY = [\n f(\"play\", false, 5, \"events\"),\n f(\"craft\", false, 10),\n f(\"friends\", false, 10, \"social\"),\n f(\"chat\", false, 20, \"social\"),\n];\n\ndescribe(\"lobby tabs\", () => {\n it(\"everything fits → all are tabs, no More\", () => {\n const { tabs, more } = splitTabs(\n [f(\"store\", true, 10), f(\"rewards\", true, 20), f(\"craft\", false, 5)],\n \"phone\",\n );\n expect(tabs.map((x) => x.id)).toEqual([\"store\", \"rewards\", \"craft\"]);\n expect(more).toEqual([]);\n });\n\n it(\"a phone keeps 4 main features + More; the rest goes under More, main ones first\", () => {\n const { tabs, more } = splitTabs([...MAIN, ...SECONDARY], \"phone\");\n expect(tabs.map((x) => x.id)).toEqual([\n \"store\",\n \"character\",\n \"leaderboard\",\n \"rewards\",\n ]);\n expect(more.map((x) => x.id)).toEqual([\n \"inventory\",\n \"craft\",\n \"play\",\n \"friends\",\n \"chat\",\n ]);\n });\n\n it(\"a tablet keeps 6 + More, a desktop lists everything\", () => {\n expect(splitTabs([...MAIN, ...SECONDARY], \"tablet\").tabs).toHaveLength(6);\n const desk = splitTabs([...MAIN, ...SECONDARY], \"desktop\");\n expect(desk.more).toEqual([]);\n expect(desk.tabs).toHaveLength(9);\n expect(tabSlots(\"desktop\")).toBe(Number.POSITIVE_INFINITY);\n });\n\n it(\"no features → no tabs\", () => {\n expect(splitTabs([], \"phone\")).toEqual({ tabs: [], more: [] });\n });\n});\n\ndescribe(\"mini-game\", () => {\n it(\"prefers weekly_score and skips disabled boards\", () => {\n expect(\n pickLeaderboard({\n Definitions: { a: { IsEnabled: true }, weekly_score: {} },\n })?.LeaderboardID,\n ).toBe(\"weekly_score\");\n expect(\n pickLeaderboard({\n Definitions: { b: { IsEnabled: false }, a: { IsEnabled: true } },\n })?.LeaderboardID,\n ).toBe(\"a\");\n // A board the server fills (invites) is never where the mini-game sends its score.\n expect(\n pickLeaderboard({\n Definitions: {\n a_invites: { ScoreSources: [{ SourceType: \"ReferralInvite\" }] },\n b: {},\n },\n })?.LeaderboardID,\n ).toBe(\"b\");\n expect(pickLeaderboard(undefined)).toBeNull();\n });\n\n it(\"reports rounds only when a quest counts them\", () => {\n expect(\n questsCountGames({\n Quests: { q: { Objectives: { o: { MetricID: \"games_played\" } } } },\n }),\n ).toBe(true);\n expect(\n questsCountGames({\n Quests: {\n q: {\n Objectives: {\n o: { MetricID: \"games_played\", Source: \"SystemEvent\" },\n },\n },\n },\n }),\n ).toBe(false);\n expect(questsCountGames({ Quests: {} })).toBe(false);\n });\n});\n\ndescribe(\"a round feeds events and seasons\", () => {\n it(\"finds the events that pay for a round, including chain phases\", () => {\n const round = {\n SourceType: \"CustomAction\",\n Params: { ActionName: PLAY_ROUND_ACTION },\n };\n expect(\n eventsCountingRounds({\n Definitions: {\n festival: { Content: { TokenSources: [round] } },\n shop_only: {\n Content: { TokenSources: [{ SourceType: \"StorePurchase\" }] },\n },\n chain: {\n Events: [\n { Content: { TokenSources: [] } },\n { Content: { TokenSources: [round] } },\n ],\n },\n other_action: {\n Content: {\n TokenSources: [\n {\n SourceType: \"CustomAction\",\n Params: { ActionName: \"watch_ad\" },\n },\n ],\n },\n },\n },\n }),\n ).toEqual([\"festival\", \"chain\"]);\n expect(eventsCountingRounds(undefined)).toEqual([]);\n });\n\n it(\"grants status only to chains open to clients\", () => {\n expect(\n seasonChainsForRounds({\n Chains: {\n main: { GrantTokensAccessMode: \"Both\" },\n pro: { GrantTokensAccessMode: \"ServerOnly\" },\n own: { GrantTokensAccessMode: \"ClientOnly\" },\n fan: {},\n },\n }),\n ).toEqual([\"main\", \"own\"]);\n });\n});\n"
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
"path": "model.ts",
|
|
77
|
-
"content": "// Pure helpers of the lobby and its mini-game — covered by model.test.ts.\n\nimport {\n sortFeatures,\n type FeatureMenuItem,\n type Layout,\n} from \"@idosgames/react/ui\";\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\n/** The pseudo-feature \"More\" — the rest of the features, as a grid. */\nexport const MORE = \"__more\";\n\nexport interface LobbyFeature extends FeatureMenuItem {\n primary?: boolean;\n}\n\n/** How many tabs fit next to \"More\" at this width (the desktop rail lists everything). */\nexport function tabSlots(layout: Layout): number {\n return layout === \"phone\"\n ? 5\n : layout === \"tablet\"\n ? 7\n : Number.POSITIVE_INFINITY;\n}\n\n/**\n * Splits the game's features into the lobby's tabs and \"More\": main (primary) features by their\n * order take the tab slots first, secondary ones fill what is left; the rest goes under \"More\".\n * \"More\" appears only when it has something — a game with three screens has three tabs.\n */\nexport function splitTabs<T extends LobbyFeature>(\n features: readonly T[],\n layout: Layout,\n): { tabs: T[]; more: T[] } {\n const primary = [...features]\n .filter((f) => f.primary)\n .sort(\n (a, b) =>\n (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),\n );\n const secondary = sortFeatures(features.filter((f) => !f.primary));\n const ordered = [...primary, ...secondary];\n const slots = tabSlots(layout);\n if (ordered.length <= slots) return { tabs: ordered, more: [] };\n // One slot goes to \"More\" itself; main features claim the rest first.\n const room = Math.max(1, slots - 1);\n return { tabs: ordered.slice(0, room), more: ordered.slice(room) };\n}\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 showcase needs neither events-ui nor season-ui 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"
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
"path": "module.ts",
|
|
81
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeLobbyPanel } from \"./Lobby\";\nimport { makePlayScreen } from \"./PlayScreen\";\nimport { t } from \"./i18n\";\n\n// The Showcase template — the lobby a new title starts from when no other template fits the idea.\n// It draws no screen of its own besides a tiny mini-game: the shop, heroes, leaders, rewards and the\n// rest are screen MODULES (store-ui, character-ui, leaderboard-ui, rewards-ui…) registered as\n// features, and the lobby lays out whatever is installed. Its demo settings are the currencies and\n// items the screens share (title-seed.json); each screen module brings its own section.\nexport const showcaseModule: Module = defineModule({\n id: \"showcase\",\n meta: {\n name: \"Showcase\",\n type: \"game\",\n genre: \"casual\",\n engine: \"dom\",\n },\n // The player ID / \"Log out\" and the menu of features are this mode's own header and tabs: the\n // host's floating copies would cover the tab bar. With a shared HUD installed (game-hud) the HUD\n // draws them instead — shouldDraw is false then.\n sharedUi: { drawsOwn: [\"account\", \"menu\"] },\n setup(ctx) {\n ctx.registerRoute({\n id: \"showcase\",\n label: t(\"play\"),\n icon: \"⭐\",\n order: 10,\n });\n ctx.features.register({\n id: \"play\",\n label: t(\"play\"),\n icon: \"star\",\n group: \"events\",\n order: 5,\n Screen: makePlayScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"lobby\",\n slot: \"overlay\",\n component: makeLobbyPanel({\n features: ctx.features,\n drawBalances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n drawAccount: ctx.sharedUi.shouldDraw(\"account\"),\n }),\n });\n\n // The lobby is ordinary React in the DOM: an agent reads it as a tree and presses the buttons\n // like a person. Only what is not on screen goes here.\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n features: ctx.features\n .list()\n .map((f) => ({\n id: f.id,\n primary: f.primary === true,\n badge: f.badge,\n module: f.moduleId,\n })),\n drawsBalances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n drawsAccount: ctx.sharedUi.shouldDraw(\"account\"),\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
"path": "PlayScreen.tsx",
|
|
85
|
-
"content": "import { useCallback, useState, type ReactNode } from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} 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 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 showcase's own game — the one thing the lobby has besides other modules' screens. A round\n// submits its score to the title's leaderboard, reports itself to quests that count rounds, and\n// feeds the events and seasons that pay for a round; the table itself is the leaderboard-ui screen\n// (\"Leaderboard\" opens it).\n\nexport function makePlayScreen(features: FeatureRegistry) {\n return function PlayScreen(_: FeatureScreenProps): 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\") ? (\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/**\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"
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
"path": "module.meta.json",
|
|
89
|
-
"content": "{\n \"id\": \"showcase\",\n \"type\": \"template\",\n \"summary\": \"The lobby a new title starts from: balances, the profile and the game's screens (shop, heroes, leaders, rewards, …) as tabs and 'More', plus a tiny mini-game.\",\n \"description\": \"The minimal complete app a new title starts from when no other template fits the idea. It is a LOBBY: every screen is another catalog module registered as a feature of the game (ctx.features) — store-ui, inventory-ui, leaderboard-ui, rewards-ui and the other *-ui modules — and the lobby lays out whatever is installed: tabs at the bottom with 'More' on a phone, a centred tab pill on a tablet, a side rail on a desktop, badges on every tab. Its own content is only a 15-second tap mini-game that submits a score to the title's leaderboard and reports rounds to quests. The header shows the balances (reward icons fly into them) and the player's profile. Remove a screen module from src/modules.ts and its tab disappears; add one and it appears. Ships title-seed.json with the currencies and items the screens share; each screen module brings its own section.\",\n \"provides\": [\n \"lobby for a game made of screen modules\",\n \"bottom tabs with More on phones, tab pill on tablets, side rail on desktops\",\n \"badges on tabs\",\n \"balance counters that reward icons fly into\",\n \"player profile button\",\n \"tap mini-game that submits a leaderboard score\",\n \"demo currencies and items\"\n ],\n \"tags\": [\n \"starter\",\n \"showcase\",\n \"lobby\",\n \"menu\",\n \"tabs\",\n \"mini-game\",\n \"casual\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
90
|
-
}
|
|
91
|
-
]
|
|
92
|
-
}
|