@idosgames/mcp 0.1.20 → 0.1.22

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 (37) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +10 -10
  3. package/registry/index.json +141 -141
  4. package/registry/modules/ai.json +7 -7
  5. package/registry/modules/board-game.json +4 -4
  6. package/registry/modules/boosts.json +3 -3
  7. package/registry/modules/character.json +7 -7
  8. package/registry/modules/chat.json +7 -7
  9. package/registry/modules/collections.json +7 -7
  10. package/registry/modules/coop-events.json +7 -7
  11. package/registry/modules/craft.json +7 -7
  12. package/registry/modules/deals.json +7 -7
  13. package/registry/modules/events.json +7 -7
  14. package/registry/modules/friends.json +7 -7
  15. package/registry/modules/idle-rpg.json +4 -4
  16. package/registry/modules/inventory.json +3 -3
  17. package/registry/modules/invite.json +7 -7
  18. package/registry/modules/leaderboard.json +7 -7
  19. package/registry/modules/lootboxes.json +3 -3
  20. package/registry/modules/mailbox.json +3 -3
  21. package/registry/modules/marketplace.json +7 -7
  22. package/registry/modules/mini-game.json +7 -7
  23. package/registry/modules/multiplayer.json +7 -7
  24. package/registry/modules/premium.json +7 -7
  25. package/registry/modules/profile.json +7 -7
  26. package/registry/modules/pvp.json +3 -3
  27. package/registry/modules/quests.json +3 -3
  28. package/registry/modules/rewards.json +7 -7
  29. package/registry/modules/season.json +7 -7
  30. package/registry/modules/store.json +7 -7
  31. package/registry/modules/tutorials.json +7 -7
  32. package/registry/modules/voxelcraft.json +6 -6
  33. package/registry/modules/workshop.json +7 -7
  34. package/registry/skills/authentication.json +1 -1
  35. package/registry/skills/idosgames-module-contract.json +1 -1
  36. package/registry/skills/idosgames-project-structure.json +1 -1
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
@@ -33,15 +33,15 @@
33
33
  "name": "iDos Games",
34
34
  "url": "https://idosgames.com"
35
35
  },
36
- "version": "0.1.0"
36
+ "version": "0.1.1"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.19.1",
40
- "@idosgames/module-sdk": "0.7.0",
41
- "@idosgames/react": "0.3.0",
39
+ "@idosgames/core": "0.20.0",
40
+ "@idosgames/module-sdk": "0.8.1",
41
+ "@idosgames/react": "0.4.2",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "1996992148c6539aacebc78427829df8c778879e3a89d7ca5d948f081c2bd4e0",
44
+ "contentHash": "9c8b1bfa6b479da0a046ff6b5ef61d1f01bec41efafca6941e0075343a780610",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  {
55
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.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"
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.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?.MyBids ?? []} 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: v.wellSoft,\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
57
  },
58
58
  {
59
59
  "path": "model.ts",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "path": "module.meta.json",
68
- "content": "{\n \"id\": \"marketplace\",\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"
68
+ "content": "{\n \"id\": \"marketplace\",\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.1\"\n}\n"
69
69
  }
70
70
  ]
71
71
  }
@@ -27,15 +27,15 @@
27
27
  "name": "iDos Games",
28
28
  "url": "https://idosgames.com"
29
29
  },
30
- "version": "0.1.0"
30
+ "version": "0.1.1"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.19.1",
34
- "@idosgames/module-sdk": "0.7.0",
35
- "@idosgames/react": "0.3.0",
33
+ "@idosgames/core": "0.20.0",
34
+ "@idosgames/module-sdk": "0.8.1",
35
+ "@idosgames/react": "0.4.2",
36
36
  "react": "19.2.7"
37
37
  },
38
- "contentHash": "26089593df1b939aee3317743caeda47f82971bfd85dbd73eb9e95a9bc5e3d5c",
38
+ "contentHash": "eb39139d93bfb84e5a20d2485b081a264234452b5d90978fdf15b940cf3b7ac0",
39
39
  "files": [
40
40
  {
41
41
  "path": "i18n.ts",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  {
53
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"
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, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 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
55
  },
56
56
  {
57
57
  "path": "model.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
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"
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.1\"\n}\n"
67
67
  }
68
68
  ]
69
69
  }
@@ -32,15 +32,15 @@
32
32
  "name": "iDos Games",
33
33
  "url": "https://idosgames.com"
34
34
  },
35
- "version": "0.1.0"
35
+ "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.19.1",
39
- "@idosgames/module-sdk": "0.7.0",
40
- "@idosgames/react": "0.3.0",
38
+ "@idosgames/core": "0.20.0",
39
+ "@idosgames/module-sdk": "0.8.1",
40
+ "@idosgames/react": "0.4.2",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "b318338a4342f30fd665899803aa10ce32764cc1afb3b709b297e5bf329cdeb7",
43
+ "contentHash": "14799b9981c650f938ddccbe51d243f5f6aeaf53105d7bd2536dffe6eabf818b",
44
44
  "files": [
45
45
  {
46
46
  "path": "i18n.ts",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  {
62
62
  "path": "MultiplayerScreen.tsx",
63
- "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { RoomListItem } from \"@idosgames/core\";\nimport type {\n FeatureScreenProps,\n ModeRegistry,\n MultiplayerGame,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n SectionTitle,\n Spinner,\n configSection,\n errorText,\n outlined,\n panel,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n cleanCode,\n defaultSettings,\n memberRows,\n newCode,\n settingsFromState,\n visibleRooms,\n type MultiplayerSection,\n} from \"./model\";\nimport type { Rooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system's screen (a lobby tab): the rooms of the games this app plays online, \"Create a\n// room\" for one of them, joining by code — and, once in a room, its members, \"Ready\", the room chat and\n// the host's \"Play\", which takes the whole room into the game. Opened with `{ mode }` it shows one\n// game's rooms (the lobby's \"With friends\"); with `{ mode, create: true }` straight on \"Create\" (a\n// game's \"Open for friends\").\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n\nconst useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\nexport function makeMultiplayerScreen(deps: ScreenDeps) {\n return function MultiplayerScreen({ args }: FeatureScreenProps): ReactNode {\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const mode = typeof args?.mode === \"string\" ? args.mode : null;\n return state.session ? (\n <Room deps={deps} />\n ) : (\n <Rooms deps={deps} mode={mode} create={args?.create === true} />\n );\n };\n}\n\n/** A game's label: its own, else its route's. */\nfunction gameLabel(\n game: MultiplayerGame | undefined,\n modes: ModeRegistry,\n mode: string | null | undefined,\n): string {\n return (\n game?.label ?? modes.list().find((m) => m.id === mode)?.label ?? mode ?? \"\"\n );\n}\n\n// ── The list of rooms ────────────────────────────────────────────────────────────────────────\n\nfunction Rooms({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n shown.map((r, i) => (\n <Card\n key={r.RoomID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"globe\" size={34} />\n <div style={{ minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {r.Name || t(\"room\")}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {gameLabel(\n deps.games.getGame(r.Mode ?? \"\"),\n deps.modes,\n r.Mode,\n )}{\" \"}\n · {t(\"players\")}: {r.MemberCount ?? 0} / {r.MaxPlayers ?? 8}\n </div>\n </div>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === r.RoomID}\n disabled={(r.MemberCount ?? 0) >= (r.MaxPlayers ?? 8)}\n onClick={() => void join(r.RoomID ?? \"\")}\n >\n {t(\"join\")}\n </Button>\n </Card>\n ))\n )}\n {popup === \"create\" ? (\n <Popup title={t(\"create\")} onClose={() => setPopup(null)} width={420}>\n <CreateForm deps={deps} games={games} preset={mode} />\n </Popup>\n ) : popup === \"code\" ? (\n <Popup title={t(\"byCode\")} onClose={() => setPopup(null)} width={400}>\n <CodeForm onJoin={(id, code) => void join(id, code)} />\n </Popup>\n ) : null}\n </div>\n );\n}\n\nconst input = {\n ...panel,\n padding: \"10px 12px\",\n color: \"#fff\",\n fontSize: 15,\n width: \"100%\",\n boxSizing: \"border-box\" as const,\n};\n\nfunction CreateForm({\n deps,\n games,\n preset,\n}: {\n deps: ScreenDeps;\n games: MultiplayerGame[];\n preset: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [mode, setMode] = useState(\n games.find((g) => g.mode === preset)?.mode ?? games[0]?.mode ?? \"\",\n );\n const game = games.find((g) => g.mode === mode);\n const cap = Math.min(\n Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")\n ?.MaxPlayersPerRoom ?? 8,\n ),\n game?.maxPlayers ?? 8,\n );\n const sizes = [2, 4, 8].filter((n) => n <= cap);\n const [name, setName] = useState(\"\");\n const [max, setMax] = useState(sizes[sizes.length - 1] ?? 2);\n const [priv, setPriv] = useState(false);\n const [settings, setSettings] = useState(() => defaultSettings(game));\n const [busy, setBusy] = useState(false);\n useEffect(() => setSettings(defaultSettings(game)), [mode]);\n\n if (!game) return <EmptyState glyph=\"globe\" text={t(\"noGames\")} />;\n\n const create = async () => {\n setBusy(true);\n const error = await deps.rooms.create({\n name: name.trim() || t(\"room\"),\n mode,\n maxPlayers: max,\n private: priv,\n joinCode: newCode(),\n settings,\n });\n setBusy(false);\n if (error) return toast(errorText(error), \"error\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n {games.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {games.map((g) => (\n <Button\n key={g.mode}\n size=\"sm\"\n tone={g.mode === mode ? \"gold\" : \"grey\"}\n onClick={() => setMode(g.mode)}\n >\n {gameLabel(g, deps.modes, g.mode)}\n </Button>\n ))}\n </div>\n ) : null}\n <input\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={input}\n />\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n {t(\"players\")}:\n {sizes.map((n) => (\n <Button\n key={n}\n size=\"sm\"\n tone={max === n ? \"gold\" : \"grey\"}\n onClick={() => setMax(n)}\n >\n {n}\n </Button>\n ))}\n </div>\n <Button\n tone={priv ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setPriv(!priv)}\n >\n <Icon glyph=\"lock\" size={16} /> {priv ? t(\"private\") : t(\"public\")}\n </Button>\n {(game.settings ?? []).map((field) =>\n field.kind === \"toggle\" ? (\n <Button\n key={field.id}\n size=\"sm\"\n tone={settings[field.id] === true ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({\n ...settings,\n [field.id]: settings[field.id] !== true,\n })\n }\n >\n {settings[field.id] === true ? \"✓ \" : \"\"}\n {field.label}\n </Button>\n ) : (\n <div key={field.id} style={{ display: \"grid\", gap: 6 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {field.label}\n </span>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {(field.options ?? []).map((o) => (\n <Button\n key={o.value}\n size=\"sm\"\n tone={settings[field.id] === o.value ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({ ...settings, [field.id]: o.value })\n }\n >\n {o.label}\n </Button>\n ))}\n </div>\n </div>\n ),\n )}\n <Button tone=\"green\" busy={busy} onClick={() => void create()}>\n {t(\"create\")}\n </Button>\n </div>\n );\n}\n\nfunction CodeForm({\n onJoin,\n}: {\n onJoin: (roomID: string, code: string) => void;\n}): ReactNode {\n const close = usePopupClose();\n const [invite, setInvite] = useState(\"\");\n // An invite is \"<room id>:<code>\" (what the room screen shows for copying); a bare id works too.\n const [roomId, code = \"\"] = invite.trim().split(\":\");\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={invite}\n placeholder={t(\"invitePlaceholder\")}\n onChange={(e) => setInvite(e.target.value)}\n style={input}\n />\n <Button\n tone=\"green\"\n disabled={!roomId}\n onClick={() => {\n close();\n onJoin(roomId ?? \"\", cleanCode(code));\n }}\n >\n {t(\"join\")}\n </Button>\n </div>\n );\n}\n\n// ── In a room ────────────────────────────────────────────────────────────────────────────────\n\nfunction Room({ deps }: { deps: ScreenDeps }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const now = useNow();\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const session = state.session!;\n const game = deps.games.getGame(session.mode ?? \"\");\n const timeout = Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")?.MemberTimeoutMs ??\n 15_000,\n );\n const rows = memberRows(\n session.members.values(),\n session.selfId,\n session.hostId,\n now,\n timeout,\n );\n const me = rows.find((r) => r.me);\n const nameOf = (id: string) =>\n rows.find((r) => r.userID === id)?.name ?? t(\"player\");\n const settings = settingsFromState(\n game,\n session.members.get(session.hostId)?.State,\n );\n const invite = `${session.roomId}${state.joinCode ? `:${state.joinCode}` : \"\"}`;\n\n const act = async (run: () => Promise<string | null>) => {\n setBusy(true);\n const error = await run();\n setBusy(false);\n if (error) toast(errorText(error), \"error\");\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {gameLabel(game, deps.modes, session.mode)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"players\")}: {rows.length}\n {(game?.settings ?? []).map((f) => {\n const value = settings[f.id];\n const shown =\n f.kind === \"toggle\"\n ? value === true\n ? f.label\n : null\n : `${f.label}: ${f.options?.find((o) => o.value === value)?.label ?? value}`;\n return shown ? ` · ${shown}` : null;\n })}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() =>\n void navigator.clipboard\n ?.writeText(invite)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n border: \"none\",\n background: \"none\",\n padding: 0,\n textAlign: \"left\",\n cursor: \"pointer\",\n ...outlined,\n fontSize: 12,\n color: v.gold,\n }}\n >\n <Icon glyph=\"copy\" size={14} /> {t(\"invite\")}: {invite}\n </button>\n </div>\n\n {state.started ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n onClick={() => deps.rooms.resume()}\n >\n {t(\"backToGame\")}\n </Button>\n ) : null}\n {state.error ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(state.error === \"noGame\" ? \"noGame\" : \"startFailed\")}\n </div>\n ) : null}\n\n <SectionTitle>{t(\"members\")}</SectionTitle>\n {rows.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n opacity: m.online ? 1 : 0.5,\n }}\n >\n <Icon glyph={m.host ? \"crown\" : \"user\"} size={24} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\n </span>\n {m.ready ? <Icon glyph=\"check\" size={20} /> : null}\n {session.isHost && !m.me && !state.started ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(() => deps.rooms.kick(m.userID))}\n >\n {t(\"kick\")}\n </Button>\n ) : null}\n </Card>\n ))}\n\n {state.started ? null : session.isHost ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n disabled={!game}\n onClick={() => void act(() => deps.rooms.start())}\n data-tutorial-anchor=\"multiplayer:play\"\n >\n {t(\"play\")} · {gameLabel(game, deps.modes, session.mode)}\n </Button>\n ) : (\n <>\n <Button\n tone={me?.ready ? \"grey\" : \"green\"}\n busy={busy}\n onClick={() => void act(() => deps.rooms.setReady(!me?.ready))}\n >\n {me?.ready ? t(\"notReady\") : t(\"ready\")}\n </Button>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"waitHost\")}\n </div>\n </>\n )}\n\n {session.chatMode === \"ServerRelay\" ? (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div\n style={{\n display: \"grid\",\n gap: 4,\n maxHeight: 180,\n overflowY: \"auto\",\n }}\n >\n {state.chat.length === 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noChat\")}\n </div>\n ) : (\n state.chat.map((c, i) => (\n <div key={i} style={{ fontSize: 13, color: \"#fff\" }}>\n <b>{nameOf(c.from)}:</b> {c.text}\n </div>\n ))\n )}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={200}\n placeholder={t(\"message\")}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key !== \"Enter\") return;\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n style={{ ...input, flex: 1 }}\n />\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => {\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n >\n {t(\"send\")}\n </Button>\n </div>\n </div>\n ) : null}\n\n <Button tone=\"grey\" size=\"sm\" onClick={() => void deps.rooms.leave()}>\n {t(\"leave\")}\n </Button>\n </div>\n );\n}\n"
63
+ "content": "import {\n useCallback,\n useEffect,\n useMemo,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { RoomListItem } from \"@idosgames/core\";\nimport type {\n FeatureScreenProps,\n ModeRegistry,\n MultiplayerGame,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n SectionTitle,\n Spinner,\n configSection,\n errorText,\n outlined,\n panel,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n cleanCode,\n defaultSettings,\n memberRows,\n newCode,\n settingsFromState,\n visibleRooms,\n type MultiplayerSection,\n} from \"./model\";\nimport type { Rooms } from \"./rooms\";\nimport { t } from \"./i18n\";\n\n// The rooms system's screen (a lobby tab): the rooms of the games this app plays online, \"Create a\n// room\" for one of them, joining by code — and, once in a room, its members, \"Ready\", the room chat and\n// the host's \"Play\", which takes the whole room into the game. Opened with `{ mode }` it shows one\n// game's rooms (the lobby's \"With friends\"); with `{ mode, create: true }` straight on \"Create\" (a\n// game's \"Open for friends\").\n\nexport interface ScreenDeps {\n rooms: Rooms;\n games: MultiplayerRegistry;\n modes: ModeRegistry;\n}\n\nconst useGames = (games: MultiplayerRegistry) =>\n useSyncExternalStore(games.subscribe, games.listGames, games.listGames);\n\nexport function makeMultiplayerScreen(deps: ScreenDeps) {\n return function MultiplayerScreen({ args }: FeatureScreenProps): ReactNode {\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const mode = typeof args?.mode === \"string\" ? args.mode : null;\n return state.session ? (\n <Room deps={deps} />\n ) : (\n <Rooms deps={deps} mode={mode} create={args?.create === true} />\n );\n };\n}\n\n/** A game's label: its own, else its route's. */\nfunction gameLabel(\n game: MultiplayerGame | undefined,\n modes: ModeRegistry,\n mode: string | null | undefined,\n): string {\n return (\n game?.label ?? modes.list().find((m) => m.id === mode)?.label ?? mode ?? \"\"\n );\n}\n\n// ── The list of rooms ────────────────────────────────────────────────────────────────────────\n\nfunction Rooms({\n deps,\n mode,\n create,\n}: {\n deps: ScreenDeps;\n mode: string | null;\n create: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const stagger = useStagger();\n const games = useGames(deps.games);\n const [rooms, setRooms] = useState<RoomListItem[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [popup, setPopup] = useState<\"create\" | \"code\" | null>(\n create ? \"create\" : null,\n );\n const modesList = useMemo(() => games.map((g) => g.mode), [games]);\n\n const load = useCallback(async () => {\n const res = await client.multiplayer.listRooms(0, 50);\n setRooms(res.ok ? (res.data.Rooms ?? []) : []);\n }, [client]);\n useEffect(() => {\n void load();\n const id = setInterval(() => void load(), 5_000);\n return () => clearInterval(id);\n }, [load]);\n\n const join = async (roomID: string, code?: string) => {\n setBusy(roomID);\n const error = await deps.rooms.join(roomID, code);\n setBusy(null);\n if (error)\n toast(\n error === \"inProgress\" ? t(\"inProgress\") : errorText(error),\n \"error\",\n );\n };\n\n const shown = visibleRooms(rooms ?? [], modesList, mode);\n const title = mode\n ? `${t(\"roomsOf\")} ${gameLabel(deps.games.getGame(mode), deps.modes, mode)}`\n : t(\"open\");\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n attract\n onClick={() => setPopup(\"create\")}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"multiplayer:create\"\n >\n <Icon glyph=\"plus\" size={18} /> {t(\"create\")}\n </Button>\n <Button tone=\"blue\" onClick={() => setPopup(\"code\")}>\n {t(\"byCode\")}\n </Button>\n </div>\n <SectionTitle>{title}</SectionTitle>\n {!rooms ? (\n <Center>\n <Spinner />\n </Center>\n ) : shown.length === 0 ? (\n <EmptyState glyph=\"globe\" text={t(\"noRooms\")} />\n ) : (\n shown.map((r, i) => (\n <Card\n key={r.RoomID}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"auto 1fr auto\",\n alignItems: \"center\",\n gap: 12,\n ...stagger(i),\n }}\n >\n <Icon glyph=\"globe\" size={34} />\n <div style={{ minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {r.Name || t(\"room\")}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {gameLabel(\n deps.games.getGame(r.Mode ?? \"\"),\n deps.modes,\n r.Mode,\n )}{\" \"}\n · {t(\"players\")}: {r.MemberCount ?? 0} / {r.MaxPlayers ?? 8}\n </div>\n </div>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === r.RoomID}\n disabled={(r.MemberCount ?? 0) >= (r.MaxPlayers ?? 8)}\n onClick={() => void join(r.RoomID ?? \"\")}\n >\n {t(\"join\")}\n </Button>\n </Card>\n ))\n )}\n {popup === \"create\" ? (\n <Popup title={t(\"create\")} onClose={() => setPopup(null)} width={420}>\n <CreateForm deps={deps} games={games} preset={mode} />\n </Popup>\n ) : popup === \"code\" ? (\n <Popup title={t(\"byCode\")} onClose={() => setPopup(null)} width={400}>\n <CodeForm onJoin={(id, code) => void join(id, code)} />\n </Popup>\n ) : null}\n </div>\n );\n}\n\nconst input = {\n ...panel,\n padding: \"10px 12px\",\n color: v.text,\n fontSize: 15,\n width: \"100%\",\n boxSizing: \"border-box\" as const,\n};\n\nfunction CreateForm({\n deps,\n games,\n preset,\n}: {\n deps: ScreenDeps;\n games: MultiplayerGame[];\n preset: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [mode, setMode] = useState(\n games.find((g) => g.mode === preset)?.mode ?? games[0]?.mode ?? \"\",\n );\n const game = games.find((g) => g.mode === mode);\n const cap = Math.min(\n Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")\n ?.MaxPlayersPerRoom ?? 8,\n ),\n game?.maxPlayers ?? 8,\n );\n const sizes = [2, 4, 8].filter((n) => n <= cap);\n const [name, setName] = useState(\"\");\n const [max, setMax] = useState(sizes[sizes.length - 1] ?? 2);\n const [priv, setPriv] = useState(false);\n const [settings, setSettings] = useState(() => defaultSettings(game));\n const [busy, setBusy] = useState(false);\n useEffect(() => setSettings(defaultSettings(game)), [mode]);\n\n if (!game) return <EmptyState glyph=\"globe\" text={t(\"noGames\")} />;\n\n const create = async () => {\n setBusy(true);\n const error = await deps.rooms.create({\n name: name.trim() || t(\"room\"),\n mode,\n maxPlayers: max,\n private: priv,\n joinCode: newCode(),\n settings,\n });\n setBusy(false);\n if (error) return toast(errorText(error), \"error\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n {games.length > 1 ? (\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {games.map((g) => (\n <Button\n key={g.mode}\n size=\"sm\"\n tone={g.mode === mode ? \"gold\" : \"grey\"}\n onClick={() => setMode(g.mode)}\n >\n {gameLabel(g, deps.modes, g.mode)}\n </Button>\n ))}\n </div>\n ) : null}\n <input\n value={name}\n maxLength={40}\n placeholder={t(\"roomName\")}\n onChange={(e) => setName(e.target.value)}\n style={input}\n />\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 14,\n }}\n >\n {t(\"players\")}:\n {sizes.map((n) => (\n <Button\n key={n}\n size=\"sm\"\n tone={max === n ? \"gold\" : \"grey\"}\n onClick={() => setMax(n)}\n >\n {n}\n </Button>\n ))}\n </div>\n <Button\n tone={priv ? \"gold\" : \"grey\"}\n size=\"sm\"\n onClick={() => setPriv(!priv)}\n >\n <Icon glyph=\"lock\" size={16} /> {priv ? t(\"private\") : t(\"public\")}\n </Button>\n {(game.settings ?? []).map((field) =>\n field.kind === \"toggle\" ? (\n <Button\n key={field.id}\n size=\"sm\"\n tone={settings[field.id] === true ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({\n ...settings,\n [field.id]: settings[field.id] !== true,\n })\n }\n >\n {settings[field.id] === true ? \"✓ \" : \"\"}\n {field.label}\n </Button>\n ) : (\n <div key={field.id} style={{ display: \"grid\", gap: 6 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {field.label}\n </span>\n <div style={{ display: \"flex\", gap: 6, flexWrap: \"wrap\" }}>\n {(field.options ?? []).map((o) => (\n <Button\n key={o.value}\n size=\"sm\"\n tone={settings[field.id] === o.value ? \"gold\" : \"grey\"}\n onClick={() =>\n setSettings({ ...settings, [field.id]: o.value })\n }\n >\n {o.label}\n </Button>\n ))}\n </div>\n </div>\n ),\n )}\n <Button tone=\"green\" busy={busy} onClick={() => void create()}>\n {t(\"create\")}\n </Button>\n </div>\n );\n}\n\nfunction CodeForm({\n onJoin,\n}: {\n onJoin: (roomID: string, code: string) => void;\n}): ReactNode {\n const close = usePopupClose();\n const [invite, setInvite] = useState(\"\");\n // An invite is \"<room id>:<code>\" (what the room screen shows for copying); a bare id works too.\n const [roomId, code = \"\"] = invite.trim().split(\":\");\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={invite}\n placeholder={t(\"invitePlaceholder\")}\n onChange={(e) => setInvite(e.target.value)}\n style={input}\n />\n <Button\n tone=\"green\"\n disabled={!roomId}\n onClick={() => {\n close();\n onJoin(roomId ?? \"\", cleanCode(code));\n }}\n >\n {t(\"join\")}\n </Button>\n </div>\n );\n}\n\n// ── In a room ────────────────────────────────────────────────────────────────────────────────\n\nfunction Room({ deps }: { deps: ScreenDeps }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const now = useNow();\n const state = useSyncExternalStore(\n deps.rooms.subscribe,\n deps.rooms.get,\n deps.rooms.get,\n );\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const session = state.session!;\n const game = deps.games.getGame(session.mode ?? \"\");\n const timeout = Number(\n configSection<MultiplayerSection>(client, \"Multiplayer\")?.MemberTimeoutMs ??\n 15_000,\n );\n const rows = memberRows(\n session.members.values(),\n session.selfId,\n session.hostId,\n now,\n timeout,\n );\n const me = rows.find((r) => r.me);\n const nameOf = (id: string) =>\n rows.find((r) => r.userID === id)?.name ?? t(\"player\");\n const settings = settingsFromState(\n game,\n session.members.get(session.hostId)?.State,\n );\n const invite = `${session.roomId}${state.joinCode ? `:${state.joinCode}` : \"\"}`;\n\n const act = async (run: () => Promise<string | null>) => {\n setBusy(true);\n const error = await run();\n setBusy(false);\n if (error) toast(errorText(error), \"error\");\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {gameLabel(game, deps.modes, session.mode)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"players\")}: {rows.length}\n {(game?.settings ?? []).map((f) => {\n const value = settings[f.id];\n const shown =\n f.kind === \"toggle\"\n ? value === true\n ? f.label\n : null\n : `${f.label}: ${f.options?.find((o) => o.value === value)?.label ?? value}`;\n return shown ? ` · ${shown}` : null;\n })}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() =>\n void navigator.clipboard\n ?.writeText(invite)\n .then(() => toast(t(\"copied\")))\n }\n style={{\n border: \"none\",\n background: \"none\",\n padding: 0,\n textAlign: \"left\",\n cursor: \"pointer\",\n ...outlined,\n fontSize: 12,\n color: v.gold,\n }}\n >\n <Icon glyph=\"copy\" size={14} /> {t(\"invite\")}: {invite}\n </button>\n </div>\n\n {state.started ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n onClick={() => deps.rooms.resume()}\n >\n {t(\"backToGame\")}\n </Button>\n ) : null}\n {state.error ? (\n <div style={{ ...outlined, fontSize: 13, color: v.red }}>\n {t(state.error === \"noGame\" ? \"noGame\" : \"startFailed\")}\n </div>\n ) : null}\n\n <SectionTitle>{t(\"members\")}</SectionTitle>\n {rows.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n opacity: m.online ? 1 : 0.5,\n }}\n >\n <Icon glyph={m.host ? \"crown\" : \"user\"} size={24} />\n <span style={{ ...outlined, fontSize: 14, flex: 1 }}>\n {m.me ? `${m.name} (${t(\"you\")})` : m.name}\n </span>\n {m.ready ? <Icon glyph=\"check\" size={20} /> : null}\n {session.isHost && !m.me && !state.started ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(() => deps.rooms.kick(m.userID))}\n >\n {t(\"kick\")}\n </Button>\n ) : null}\n </Card>\n ))}\n\n {state.started ? null : session.isHost ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n disabled={!game}\n onClick={() => void act(() => deps.rooms.start())}\n data-tutorial-anchor=\"multiplayer:play\"\n >\n {t(\"play\")} · {gameLabel(game, deps.modes, session.mode)}\n </Button>\n ) : (\n <>\n <Button\n tone={me?.ready ? \"grey\" : \"green\"}\n busy={busy}\n onClick={() => void act(() => deps.rooms.setReady(!me?.ready))}\n >\n {me?.ready ? t(\"notReady\") : t(\"ready\")}\n </Button>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"waitHost\")}\n </div>\n </>\n )}\n\n {session.chatMode === \"ServerRelay\" ? (\n <div style={{ ...panel, padding: 10, display: \"grid\", gap: 8 }}>\n <div\n style={{\n display: \"grid\",\n gap: 4,\n maxHeight: 180,\n overflowY: \"auto\",\n }}\n >\n {state.chat.length === 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noChat\")}\n </div>\n ) : (\n state.chat.map((c, i) => (\n <div key={i} style={{ fontSize: 13, color: v.text }}>\n <b>{nameOf(c.from)}:</b> {c.text}\n </div>\n ))\n )}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={200}\n placeholder={t(\"message\")}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key !== \"Enter\") return;\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n style={{ ...input, flex: 1 }}\n />\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => {\n const msg = text;\n setText(\"\");\n void act(() => deps.rooms.sendChat(msg));\n }}\n >\n {t(\"send\")}\n </Button>\n </div>\n </div>\n ) : null}\n\n <Button tone=\"grey\" size=\"sm\" onClick={() => void deps.rooms.leave()}>\n {t(\"leave\")}\n </Button>\n </div>\n );\n}\n"
64
64
  },
65
65
  {
66
66
  "path": "rooms.ts",
@@ -68,7 +68,7 @@
68
68
  },
69
69
  {
70
70
  "path": "module.meta.json",
71
- "content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
71
+ "content": "{\n \"id\": \"multiplayer\",\n \"type\": \"feature\",\n \"summary\": \"The rooms system for any game: open rooms of the app's online games, public or private rooms by invite, members, ready and chat — the host takes the whole room into a game.\",\n \"description\": \"A ready multiplayer lobby (engine Multiplayer) for ANY game, built on the @idosgames/react/ui kit, client.multiplayer and RealtimeSession (@idosgames/core/realtime). It OWNS the player's room and its WebRTC session: the rooms of the games this app plays online (each room remembers its game), 'Create a room' for one of them (name, 2/4/8 players, public or private with an invite code, the game's own room settings), joining by invite, members (host, ready, offline), 'Ready', the room chat (server relay, when the game has no chat of its own), removing a member and leaving. The host's 'Play' takes the whole room into the game: every device switches to the game's mode and the game gets the room in its start() — games never create, list or join rooms themselves. A game plays online by registering in ctx.multiplayer (registerGame: topology, chat mode, room size, joinInProgress for late joiners, room settings, start(room)); the lobby then draws 'With friends' next to its 'Play'. Opened with { mode } the screen shows one game's rooms, with { mode, create: true } straight on 'Create'. Registers the 'multiplayer' feature, shown while multiplayer is on for the title and the app has a game that plays online. Ships title-seed.json: multiplayer on, up to 8 players per room.\",\n \"provides\": [\n \"multiplayer rooms lobby\",\n \"rooms of one game\",\n \"public and private rooms by invite\",\n \"room settings of the game\",\n \"room members with ready and host\",\n \"room chat\",\n \"host starts the game for the whole room\",\n \"late joiners into a running game\"\n ],\n \"tags\": [\n \"multiplayer\",\n \"rooms\",\n \"lobby\",\n \"realtime\",\n \"social\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
72
72
  }
73
73
  ]
74
74
  }
@@ -29,15 +29,15 @@
29
29
  "name": "iDos Games",
30
30
  "url": "https://idosgames.com"
31
31
  },
32
- "version": "0.1.0"
32
+ "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.19.1",
36
- "@idosgames/module-sdk": "0.7.0",
37
- "@idosgames/react": "0.3.0",
35
+ "@idosgames/core": "0.20.0",
36
+ "@idosgames/module-sdk": "0.8.1",
37
+ "@idosgames/react": "0.4.2",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "ec99b4243633026ae61d43679fca7b37731033cd262bae6f5cc17479d1e67e7a",
40
+ "contentHash": "5f2472fa890edc2500ec4f0703aa320a84862d0768d2ce2b576f0460adcc5eca",
41
41
  "files": [
42
42
  {
43
43
  "path": "i18n.ts",
@@ -57,11 +57,11 @@
57
57
  },
58
58
  {
59
59
  "path": "PremiumScreen.tsx",
60
- "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ResourceList,\n Timer,\n canAfford,\n configSection,\n errorText,\n outlined,\n panel,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumSection,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Fresh state on open: a pass may have run out since the last read.\n useEffect(() => {\n void client.premium.getUserState();\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </div>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #8a5a12 0%, #4a2d06 100%)\",\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function makePremiumWatcher(features: FeatureRegistry) {\n return function PremiumBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n }, [client, state?.Premium]);\n return null;\n };\n}\n"
60
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n canAfford,\n Card,\n configSection,\n EmptyState,\n errorText,\n Icon,\n outlined,\n panel,\n ResourceList,\n Timer,\n toneSurface,\n useCatalog,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n passRows,\n premiumBadge,\n type PassRow,\n type PremiumSection,\n type PremiumStateView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// VIP (Unity PremiumPopUp): every premium pass of the title, lowest tier first — what it gives, how\n// long it lasts, until when the player holds it, a free trial once, and buying it with in-game\n// currency. A pass paid in a store (real money) is the platform's checkout, not sold here. Other\n// systems (events, season) send the player here for their VIP prizes:\n// `features.open(\"premium\", { premiumID })` — that pass is marked.\n\nexport function PremiumScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const now = useNow();\n const stagger = useStagger();\n const wanted = typeof args?.premiumID === \"string\" ? args.premiumID : null;\n const rows = passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n now,\n );\n // Fresh state on open: a pass may have run out since the last read.\n useEffect(() => {\n void client.premium.getUserState();\n }, [client]);\n\n if (rows.length === 0)\n return <EmptyState glyph=\"crown\" text={t(\"noPasses\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {rows.map((row, i) => (\n <PassCard\n key={row.premiumID}\n row={row}\n marked={row.premiumID === wanted}\n style={stagger(i)}\n />\n ))}\n </div>\n );\n}\n\nfunction PassCard({\n row,\n marked,\n style,\n}: {\n row: PassRow;\n marked: boolean;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n\n useEffect(() => {\n if (marked) ref.current?.scrollIntoView({ block: \"center\" });\n }, [marked]);\n\n const done = (res: { ok: true } | { ok: false; error: string }) => {\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n kit.fx.burst(ref.current);\n toast(t(\"active\"), \"success\");\n };\n const trial = async () => {\n setBusy(\"trial\");\n const res = await client.premium.activateTrial(\n row.premiumID,\n crypto.randomUUID(),\n );\n done(res);\n };\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n row.premiumID,\n crypto.randomUUID(),\n optionID,\n );\n done(res);\n };\n\n return (\n <div ref={ref}>\n <Card\n highlight={marked}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"gold\"),\n ...style,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={52} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(row.name)}\n </div>\n {row.activeUntil ? (\n <Timer at={row.activeUntil} now={now} label={t(\"activeFor\")} />\n ) : row.durationDays > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.durationDays} {t(\"days\")}\n </div>\n ) : null}\n </div>\n {row.activeUntil ? <Icon glyph=\"check\" size={30} /> : null}\n </div>\n {row.benefits.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n {row.benefits.map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {catalog.localize(b)}\n </div>\n ))}\n </div>\n ) : null}\n {row.trialDays > 0 ? (\n <Button\n tone=\"green\"\n attract\n busy={busy === \"trial\"}\n onClick={() => void trial()}\n data-tutorial-anchor={`premium:trial:${row.premiumID}`}\n >\n {t(\"trial\")} · {row.trialDays} {t(\"days\")}\n </Button>\n ) : null}\n {row.options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable && !row.activeUntil}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor={`premium:buy:${row.premiumID}`}\n >\n {row.activeUntil ? t(\"extend\") : t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </Card>\n </div>\n );\n}\n\n/** Badge: a free trial waiting — from the config and the cached state, no request of its own. */\nexport function makePremiumWatcher(features: FeatureRegistry) {\n return function PremiumBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n features.setBadge(\n \"premium\",\n premiumBadge(\n passRows(\n configSection<PremiumSection>(client, \"Premium\"),\n state?.Premium as PremiumStateView | undefined,\n Date.now(),\n ),\n ),\n );\n }, [client, state?.Premium]);\n return null;\n };\n}\n"
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"premium\",\n \"type\": \"feature\",\n \"summary\": \"VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.\",\n \"description\": \"A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.\",\n \"provides\": [\n \"VIP passes screen\",\n \"free VIP trial\",\n \"buy or extend VIP with in-game currency\",\n \"VIP timer\",\n \"badge for a free trial\"\n ],\n \"tags\": [\n \"vip\",\n \"premium\",\n \"subscription\",\n \"monetization\",\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"
64
+ "content": "{\n \"id\": \"premium\",\n \"type\": \"feature\",\n \"summary\": \"VIP for any game: premium passes with benefits, until when the player holds one, a free trial and buying with in-game currency.\",\n \"description\": \"A ready VIP screen (Unity PremiumPopUp) for ANY game, built on the @idosgames/react/ui kit and client.premium. Every premium pass of the title, lowest tier first: its name, benefits and duration, a timer while the player holds it, a one-time free trial, and buying (or extending) it with currencies or items. A pass paid in a store (real money) is the platform's checkout and is not sold here. Events and season send the player here for their VIP prizes (ctx.features.open('premium', { premiumID }) marks that pass). Registers the 'premium' feature; its badge shows a free trial waiting. The demo pass comes with the base's settings.\",\n \"provides\": [\n \"VIP passes screen\",\n \"free VIP trial\",\n \"buy or extend VIP with in-game currency\",\n \"VIP timer\",\n \"badge for a free trial\"\n ],\n \"tags\": [\n \"vip\",\n \"premium\",\n \"subscription\",\n \"monetization\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
65
65
  }
66
66
  ]
67
67
  }