@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
@@ -31,19 +31,19 @@
31
31
  "name": "iDos Games",
32
32
  "url": "https://idosgames.com"
33
33
  },
34
- "version": "0.1.0"
34
+ "version": "0.1.1"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.19.1",
38
- "@idosgames/module-sdk": "0.7.0",
39
- "@idosgames/react": "0.3.0",
37
+ "@idosgames/core": "0.20.0",
38
+ "@idosgames/module-sdk": "0.8.1",
39
+ "@idosgames/react": "0.4.2",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "dc0cf44c410258a1aa38f31d38eba7fb895cde14c7bb4ee67b1c7ee4546b25d0",
42
+ "contentHash": "35feb5ba1e12924999427f0d56b56a5b9db239ee8f91d805bcf0c39b40349273",
43
43
  "files": [
44
44
  {
45
45
  "path": "CollectionsScreen.tsx",
46
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n CollectionTradeOfferDocument,\n FriendPublicProfile,\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 Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n albumSets,\n chestRows,\n collectionsBadge,\n currentCollection,\n grandPrizeState,\n packOptions,\n type CardRow,\n type ChestRow,\n type CollectionSection,\n type CollectionStateView,\n type PackOption,\n type SetRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections (engine Collection): an album of sets — every card the player holds or still misses,\n// a reward per completed set and a grand prize for the whole album; packs of cards (currencies) and\n// collection chests (the currency duplicates turn into); and trading a spare card with a friend.\n\ntype Tab = \"album\" | \"packs\" | \"trades\";\n\nexport function CollectionsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"album\");\n const section = configSection<CollectionSection>(client, \"Collection\");\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(section, mine);\n\n const reload = useCallback(async () => {\n await client.collection.getUserState();\n }, [client]);\n useEffect(() => {\n void reload();\n }, [reload]);\n\n if (!collection) return <EmptyState glyph=\"star\" text={t(\"noCollection\")} />;\n const sets = albumSets(collection, mine);\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={[\n {\n id: \"album\",\n label: t(\"album\"),\n badge: collectionsBadge(collection, sets, mine),\n },\n { id: \"packs\", label: t(\"packs\") },\n { id: \"trades\", label: t(\"trades\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"album\" ? (\n <Album\n collectionID={collection.CollectionID}\n name={collection.DisplayName ?? collection.CollectionID}\n grandPrize={grantLines(collection.GrandPrize)}\n prize={grandPrizeState(collection, sets, mine)}\n sets={sets}\n onChanged={reload}\n />\n ) : tab === \"packs\" ? (\n <Packs\n collectionID={collection.CollectionID}\n section={section}\n balance={Number(mine?.CollectionCurrencyBalance ?? 0)}\n onChanged={reload}\n />\n ) : (\n <Trades collectionID={collection.CollectionID} sets={sets} />\n )}\n </div>\n );\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\nfunction Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction CardTile({ card }: { card: CardRow }): ReactNode {\n const catalog = useCatalog();\n const has = card.count > 0;\n return (\n <div\n title={catalog.localize(card.name)}\n style={{\n position: \"relative\",\n aspectRatio: \"3 / 4\",\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n background: has ? v.panelDeep : \"rgba(0,0,0,.35)\",\n border: `2px solid ${has ? catalog.rarityColor(String(card.rarity)) : \"rgba(255,255,255,.08)\"}`,\n opacity: has ? 1 : 0.45,\n }}\n >\n <Icon glyph={has ? card.icon : \"lock\"} size={30} />\n {card.count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: 4,\n bottom: 2,\n ...outlined,\n fontSize: 12,\n }}\n >\n ×{card.count}\n </span>\n ) : null}\n </div>\n );\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nfunction Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionSection | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? await client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n opening.pack.optionID\n ? { selectedOptionID: opening.pack.optionID }\n : undefined,\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n\nfunction Trades({\n collectionID,\n sets,\n}: {\n collectionID: string;\n sets: SetRow[];\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [incoming, setIncoming] = useState<\n CollectionTradeOfferDocument[] | null\n >(null);\n const [mine, setMine] = useState<CollectionTradeOfferDocument[]>([]);\n const [giving, setGiving] = useState<CardRow | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const spare = sets.flatMap((s) => s.cards).filter((c) => c.spare > 0);\n\n const load = useCallback(async () => {\n const [inc, my] = await Promise.all([\n client.collection.getIncomingTradeOffers(collectionID),\n client.collection.getMyTradeOffers(collectionID),\n ]);\n setIncoming(inc.ok ? (inc.data.Offers ?? []) : []);\n setMine(my.ok ? (my.data.Offers ?? []) : []);\n }, [client, collectionID]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const act = async (\n offerID: string,\n action: \"accept\" | \"decline\" | \"cancel\",\n ) => {\n setBusy(offerID);\n const res =\n action === \"accept\"\n ? await client.collection.acceptTradeOffer(offerID)\n : action === \"decline\"\n ? await client.collection.declineTradeOffer(offerID)\n : await client.collection.cancelTradeOffer(offerID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (action === \"accept\") {\n toast(t(\"traded\"), \"success\");\n void client.collection.getUserState();\n }\n await load();\n };\n\n if (!incoming)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const pending = (o: CollectionTradeOfferDocument) =>\n !o.Status || o.Status === \"Pending\";\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"incoming\")}</SectionTitle>\n {incoming.filter(pending).length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noIncoming\")}\n </div>\n ) : (\n incoming.filter(pending).map((o) => (\n <Card key={o.OfferID} style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.SenderPublicData?.Username ?? t(\"player\")} ·{\" \"}\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n {o.RequestedCollectibleID\n ? ` ⇄ ${catalog.localize(o.RequestedCollectibleID)}`\n : \"\"}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"accept\")}\n >\n {t(\"accept\")}\n </Button>\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(o.OfferID, \"decline\")}\n >\n {t(\"decline\")}\n </Button>\n </div>\n </Card>\n ))\n )}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"sent\")}</SectionTitle>\n {mine.filter(pending).map((o) => (\n <Card\n key={o.OfferID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n </span>\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"cancel\")}\n >\n {t(\"cancel\")}\n </Button>\n </Card>\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"giveSpare\")}</SectionTitle>\n {spare.length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noSpare\")}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {spare.map((c) => (\n <button\n key={c.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setGiving(c)}\n style={{ border: \"none\", background: \"none\", padding: 0 }}\n >\n <CardTile card={c} />\n </button>\n ))}\n </div>\n )}\n </div>\n {giving ? (\n <GivePopup\n collectionID={collectionID}\n card={giving}\n onClose={() => setGiving(null)}\n onSent={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction GivePopup({\n collectionID,\n card,\n onClose,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onClose: () => void;\n onSent: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup\n title={`${t(\"offerTo\")}: ${catalog.localize(card.name)}`}\n onClose={onClose}\n width={420}\n >\n <FriendPicker collectionID={collectionID} card={card} onSent={onSent} />\n </Popup>\n );\n}\n\nfunction FriendPicker({\n collectionID,\n card,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [friends, setFriends] = useState<FriendPublicProfile[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.social\n .getFriendsList()\n .then((r) => setFriends(r.ok ? (r.data.Friends ?? []) : []));\n }, [client]);\n if (!friends)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (friends.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n const send = async (userID: string) => {\n setBusy(userID);\n const res = await client.collection.sendTradeOffer(\n collectionID,\n card.id,\n false,\n userID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"offerSent\"), \"success\");\n close();\n await onSent();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {friends.map((f) => (\n <Card\n key={f.UserID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {f.PublicData?.Username ?? t(\"player\")}\n </span>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === f.UserID}\n onClick={() => void send(f.UserID)}\n >\n {t(\"give\")}\n </Button>\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/** Badge: set rewards and the grand prize waiting — only on a title with a collection. */\nexport function makeCollectionsWatcher(features: FeatureRegistry) {\n return function CollectionsBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void client.collection.getUserState();\n }, [client]);\n useEffect(() => {\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(\n configSection<CollectionSection>(client, \"Collection\"),\n mine,\n );\n features.setBadge(\n \"collections\",\n collectionsBadge(collection, albumSets(collection, mine), mine),\n );\n }, [client, state?.Collection]);\n return null;\n };\n}\n"
46
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n CollectionTradeOfferDocument,\n FriendPublicProfile,\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 Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n albumSets,\n chestRows,\n collectionsBadge,\n currentCollection,\n grandPrizeState,\n packOptions,\n type CardRow,\n type ChestRow,\n type CollectionSection,\n type CollectionStateView,\n type PackOption,\n type SetRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Collections (engine Collection): an album of sets — every card the player holds or still misses,\n// a reward per completed set and a grand prize for the whole album; packs of cards (currencies) and\n// collection chests (the currency duplicates turn into); and trading a spare card with a friend.\n\ntype Tab = \"album\" | \"packs\" | \"trades\";\n\nexport function CollectionsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"album\");\n const section = configSection<CollectionSection>(client, \"Collection\");\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(section, mine);\n\n const reload = useCallback(async () => {\n await client.collection.getUserState();\n }, [client]);\n useEffect(() => {\n void reload();\n }, [reload]);\n\n if (!collection) return <EmptyState glyph=\"star\" text={t(\"noCollection\")} />;\n const sets = albumSets(collection, mine);\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={[\n {\n id: \"album\",\n label: t(\"album\"),\n badge: collectionsBadge(collection, sets, mine),\n },\n { id: \"packs\", label: t(\"packs\") },\n { id: \"trades\", label: t(\"trades\") },\n ]}\n value={tab}\n onChange={setTab}\n />\n {tab === \"album\" ? (\n <Album\n collectionID={collection.CollectionID}\n name={collection.DisplayName ?? collection.CollectionID}\n grandPrize={grantLines(collection.GrandPrize)}\n prize={grandPrizeState(collection, sets, mine)}\n sets={sets}\n onChanged={reload}\n />\n ) : tab === \"packs\" ? (\n <Packs\n collectionID={collection.CollectionID}\n section={section}\n balance={Number(mine?.CollectionCurrencyBalance ?? 0)}\n onChanged={reload}\n />\n ) : (\n <Trades collectionID={collection.CollectionID} sets={sets} />\n )}\n </div>\n );\n}\n\n// ── Album ────────────────────────────────────────────────────────────────────────────────────\n\nfunction Album({\n collectionID,\n name,\n grandPrize,\n prize,\n sets,\n onChanged,\n}: {\n collectionID: string;\n name: string;\n grandPrize: ReturnType<typeof grantLines>;\n prize: ReturnType<typeof grandPrizeState>;\n sets: SetRow[];\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const owned = sets.reduce((s, x) => s + x.owned, 0);\n const total = sets.reduce((s, x) => s + x.total, 0);\n\n const claimSet = async (setID: string) => {\n setBusy(setID);\n const res = await client.collection.claimSetReward(collectionID, setID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onChanged();\n };\n const claimPrize = async () => {\n setBusy(\"grand\");\n const res = await client.collection.claimGrandPrize(collectionID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n await onChanged();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(name)}\n </div>\n <ProgressBar value={owned} max={Math.max(1, total)} tone=\"gold\" />\n {grandPrize.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList lines={grandPrize} size={20} />\n {prize === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claimPrize()}\n >\n {t(\"claim\")}\n </Button>\n ) : prize === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n ) : null}\n </div>\n {sets.map((s, i) => (\n <Card key={s.setID} highlight={s.state === \"ready\"} style={stagger(i)}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(s.name)}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {s.owned} / {s.total}\n </span>\n </div>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {s.cards.map((c) => (\n <CardTile key={c.id} card={c} />\n ))}\n </div>\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\",\n }}\n >\n <ResourceList lines={s.reward} size={18} />\n {s.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === s.setID}\n onClick={() => void claimSet(s.setID)}\n data-tutorial-anchor={`collection:set:${s.setID}`}\n >\n {t(\"claim\")}\n </Button>\n ) : s.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={20} />\n ) : null}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction CardTile({ card }: { card: CardRow }): ReactNode {\n const catalog = useCatalog();\n const has = card.count > 0;\n return (\n <div\n title={catalog.localize(card.name)}\n style={{\n position: \"relative\",\n aspectRatio: \"3 / 4\",\n borderRadius: 10,\n display: \"grid\",\n placeItems: \"center\",\n background: has ? v.panelDeep : v.well,\n border: `2px solid ${has ? catalog.rarityColor(String(card.rarity)) : v.line}`,\n opacity: has ? 1 : 0.45,\n }}\n >\n <Icon glyph={has ? card.icon : \"lock\"} size={30} />\n {card.count > 1 ? (\n <span\n style={{\n position: \"absolute\",\n right: 4,\n bottom: 2,\n ...outlined,\n fontSize: 12,\n }}\n >\n ×{card.count}\n </span>\n ) : null}\n </div>\n );\n}\n\n// ── Packs and chests ─────────────────────────────────────────────────────────────────────────\n\nfunction Packs({\n collectionID,\n section,\n balance,\n onChanged,\n}: {\n collectionID: string;\n section: CollectionSection | undefined;\n balance: number;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const state = useUserState();\n const stagger = useStagger();\n const [opening, setOpening] = useState<\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow }\n | null\n >(null);\n const packs = packOptions(section);\n const chests = chestRows(section);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n if (packs.length === 0 && chests.length === 0)\n return <EmptyState glyph=\"ticket\" text={t(\"noPacks\")} />;\n const bulkOf = (p: PackOption) =>\n p.maxOpen >= 10 &&\n canAfford(\n p.price.map((l) => ({ ...l, amount: l.amount * 10 })),\n balances,\n );\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {packs.length > 0 ? (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {packs.map((p, i) => {\n const affordable = canAfford(p.price, balances);\n return (\n <Card\n key={`${p.packTypeID}:${p.optionID ?? \"\"}`}\n style={{\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {p.name}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={p.icon} size={56} />\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {p.cards} {t(\"cards\")}\n </div>\n <Button\n tone={p.price.length === 0 ? \"green\" : \"gold\"}\n disabled={!affordable}\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 1 })\n }\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`collection:pack:${p.packTypeID}`}\n >\n {p.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={p.price} size={18} />\n )}\n </Button>\n {bulkOf(p) ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n setOpening({ kind: \"pack\", pack: p, count: 10 })\n }\n style={{ width: \"100%\" }}\n >\n {t(\"open\")} ×10\n </Button>\n ) : null}\n </Card>\n );\n })}\n </div>\n ) : null}\n {chests.length > 0 ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle\n right={\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"points\")}: {balance}\n </span>\n }\n >\n {t(\"chests\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 10,\n }}\n >\n {chests.map((c) => (\n <Card\n key={c.chestID}\n style={{ display: \"grid\", gap: 8, justifyItems: \"center\" }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {c.name}\n </div>\n <Icon glyph={c.icon} size={56} />\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {c.cards} {t(\"cards\")}\n </div>\n <Button\n tone=\"gold\"\n disabled={balance < c.cost}\n onClick={() => setOpening({ kind: \"chest\", chest: c })}\n style={{ width: \"100%\" }}\n >\n {c.cost} {t(\"points\")}\n </Button>\n </Card>\n ))}\n </div>\n </div>\n ) : null}\n {opening ? (\n <OpenPopup\n collectionID={collectionID}\n opening={opening}\n onClose={() => setOpening(null)}\n onOpened={onChanged}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OpenPopup({\n collectionID,\n opening,\n onClose,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onClose: () => void;\n onOpened: () => Promise<void>;\n}): ReactNode {\n const name = opening.kind === \"pack\" ? opening.pack.name : opening.chest.name;\n const count = opening.kind === \"pack\" ? opening.count : 1;\n return (\n <Popup\n title={count > 1 ? `${name} ×${count}` : name}\n onClose={onClose}\n variant=\"center\"\n >\n <OpenBody\n collectionID={collectionID}\n opening={opening}\n onOpened={onOpened}\n />\n </Popup>\n );\n}\n\nfunction OpenBody({\n collectionID,\n opening,\n onOpened,\n}: {\n collectionID: string;\n opening:\n | { kind: \"pack\"; pack: PackOption; count: number }\n | { kind: \"chest\"; chest: ChestRow };\n onOpened: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const [got, setGot] = useState<Array<{ id: string; dup: boolean }> | null>(\n null,\n );\n\n const open = async () => {\n setBusy(true);\n const started = Date.now();\n const res =\n opening.kind === \"pack\"\n ? await client.collection.openPack(\n collectionID,\n opening.pack.packTypeID,\n opening.count,\n opening.pack.optionID\n ? { selectedOptionID: opening.pack.optionID }\n : undefined,\n )\n : await client.collection.openCollectionChest(\n collectionID,\n opening.chest.chestID,\n );\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 700 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n setGot([\n ...(res.data.GrantedCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: false,\n })),\n ...(res.data.DuplicateCollectibles ?? []).map((c) => ({\n id: c.CollectibleID,\n dup: true,\n })),\n ]);\n const bonus = grantedBy(res.data);\n if (bonus.length > 0) celebrate(bonus);\n await onOpened();\n };\n\n if (got)\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(72px, 1fr))\",\n gap: 8,\n }}\n >\n {got.map((c, i) => (\n <div\n key={`${c.id}:${i}`}\n style={{\n ...panel,\n padding: 6,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n opacity: c.dup ? 0.6 : 1,\n }}\n >\n <Icon glyph=\"star\" size={28} />\n <span style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n {catalog.localize(c.id)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 10,\n color: c.dup ? v.textDim : v.green,\n }}\n >\n {c.dup ? t(\"duplicate\") : t(\"new\")}\n </span>\n </div>\n ))}\n </div>\n <Button tone=\"blue\" onClick={close}>\n {t(\"great\")}\n </Button>\n </div>\n );\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon\n glyph={\n opening.kind === \"pack\" ? opening.pack.icon : opening.chest.icon\n }\n size={110}\n />\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void open()}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n </div>\n );\n}\n\n// ── Trades ───────────────────────────────────────────────────────────────────────────────────\n\nfunction Trades({\n collectionID,\n sets,\n}: {\n collectionID: string;\n sets: SetRow[];\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [incoming, setIncoming] = useState<\n CollectionTradeOfferDocument[] | null\n >(null);\n const [mine, setMine] = useState<CollectionTradeOfferDocument[]>([]);\n const [giving, setGiving] = useState<CardRow | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const spare = sets.flatMap((s) => s.cards).filter((c) => c.spare > 0);\n\n const load = useCallback(async () => {\n const [inc, my] = await Promise.all([\n client.collection.getIncomingTradeOffers(collectionID),\n client.collection.getMyTradeOffers(collectionID),\n ]);\n setIncoming(inc.ok ? (inc.data.Offers ?? []) : []);\n setMine(my.ok ? (my.data.Offers ?? []) : []);\n }, [client, collectionID]);\n useEffect(() => {\n void load();\n }, [load]);\n\n const act = async (\n offerID: string,\n action: \"accept\" | \"decline\" | \"cancel\",\n ) => {\n setBusy(offerID);\n const res =\n action === \"accept\"\n ? await client.collection.acceptTradeOffer(offerID)\n : action === \"decline\"\n ? await client.collection.declineTradeOffer(offerID)\n : await client.collection.cancelTradeOffer(offerID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (action === \"accept\") {\n toast(t(\"traded\"), \"success\");\n void client.collection.getUserState();\n }\n await load();\n };\n\n if (!incoming)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const pending = (o: CollectionTradeOfferDocument) =>\n !o.Status || o.Status === \"Pending\";\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"incoming\")}</SectionTitle>\n {incoming.filter(pending).length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noIncoming\")}\n </div>\n ) : (\n incoming.filter(pending).map((o) => (\n <Card key={o.OfferID} style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.SenderPublicData?.Username ?? t(\"player\")} ·{\" \"}\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n {o.RequestedCollectibleID\n ? ` ⇄ ${catalog.localize(o.RequestedCollectibleID)}`\n : \"\"}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"accept\")}\n >\n {t(\"accept\")}\n </Button>\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => void act(o.OfferID, \"decline\")}\n >\n {t(\"decline\")}\n </Button>\n </div>\n </Card>\n ))\n )}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"sent\")}</SectionTitle>\n {mine.filter(pending).map((o) => (\n <Card\n key={o.OfferID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(o.OfferedCollectibleID ?? \"\")}\n </span>\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void act(o.OfferID, \"cancel\")}\n >\n {t(\"cancel\")}\n </Button>\n </Card>\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n <SectionTitle>{t(\"giveSpare\")}</SectionTitle>\n {spare.length === 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"noSpare\")}\n </div>\n ) : (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(64px, 1fr))\",\n gap: 8,\n }}\n >\n {spare.map((c) => (\n <button\n key={c.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setGiving(c)}\n style={{ border: \"none\", background: \"none\", padding: 0 }}\n >\n <CardTile card={c} />\n </button>\n ))}\n </div>\n )}\n </div>\n {giving ? (\n <GivePopup\n collectionID={collectionID}\n card={giving}\n onClose={() => setGiving(null)}\n onSent={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction GivePopup({\n collectionID,\n card,\n onClose,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onClose: () => void;\n onSent: () => Promise<void>;\n}): ReactNode {\n const catalog = useCatalog();\n return (\n <Popup\n title={`${t(\"offerTo\")}: ${catalog.localize(card.name)}`}\n onClose={onClose}\n width={420}\n >\n <FriendPicker collectionID={collectionID} card={card} onSent={onSent} />\n </Popup>\n );\n}\n\nfunction FriendPicker({\n collectionID,\n card,\n onSent,\n}: {\n collectionID: string;\n card: CardRow;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const close = usePopupClose();\n const [friends, setFriends] = useState<FriendPublicProfile[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n void client.social\n .getFriendsList()\n .then((r) => setFriends(r.ok ? (r.data.Friends ?? []) : []));\n }, [client]);\n if (!friends)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (friends.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n const send = async (userID: string) => {\n setBusy(userID);\n const res = await client.collection.sendTradeOffer(\n collectionID,\n card.id,\n false,\n userID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"offerSent\"), \"success\");\n close();\n await onSent();\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {friends.map((f) => (\n <Card\n key={f.UserID}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {f.PublicData?.Username ?? t(\"player\")}\n </span>\n <Button\n tone=\"green\"\n size=\"sm\"\n busy={busy === f.UserID}\n onClick={() => void send(f.UserID)}\n >\n {t(\"give\")}\n </Button>\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Always-on: the badge ─────────────────────────────────────────────────────────────────────\n\n/** Badge: set rewards and the grand prize waiting — only on a title with a collection. */\nexport function makeCollectionsWatcher(features: FeatureRegistry) {\n return function CollectionsBadge(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void client.collection.getUserState();\n }, [client]);\n useEffect(() => {\n const mine = (state?.Collection ?? null) as CollectionStateView | null;\n const collection = currentCollection(\n configSection<CollectionSection>(client, \"Collection\"),\n mine,\n );\n features.setBadge(\n \"collections\",\n collectionsBadge(collection, albumSets(collection, mine), mine),\n );\n }, [client, state?.Collection]);\n return null;\n };\n}\n"
47
47
  },
48
48
  {
49
49
  "path": "i18n.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"collections\",\n \"type\": \"feature\",\n \"summary\": \"Collections for any game: an album of card sets with set rewards and a grand prize, packs and chests, trading spare cards with friends.\",\n \"description\": \"A ready collections screen (engine Collection) for ANY game, built on the @idosgames/react/ui kit and client.collection. Album: the player's collection with every set in order — the cards held (with how many) and the gaps, a reward to claim per completed set and a grand prize for the whole album. Packs: every pack type with every way to pay, opened once or ten at a time (the server rolls them — new cards and duplicates are shown), and collection chests bought with the points duplicates turn into. Trades: offers from other players (accept / decline), your own offers (cancel), and giving a spare card to a friend. Registers the 'collections' feature; its badge counts set rewards and the grand prize waiting. Ships title-seed.json: a small album with sets, a pack and a chest.\",\n \"provides\": [\n \"collection album with sets\",\n \"set rewards and grand prize\",\n \"card packs, once or ten at a time\",\n \"collection chests for points\",\n \"trading cards with friends\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\n \"collection\",\n \"album\",\n \"cards\",\n \"stickers\",\n \"trading\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
66
+ "content": "{\n \"id\": \"collections\",\n \"type\": \"feature\",\n \"summary\": \"Collections for any game: an album of card sets with set rewards and a grand prize, packs and chests, trading spare cards with friends.\",\n \"description\": \"A ready collections screen (engine Collection) for ANY game, built on the @idosgames/react/ui kit and client.collection. Album: the player's collection with every set in order — the cards held (with how many) and the gaps, a reward to claim per completed set and a grand prize for the whole album. Packs: every pack type with every way to pay, opened once or ten at a time (the server rolls them — new cards and duplicates are shown), and collection chests bought with the points duplicates turn into. Trades: offers from other players (accept / decline), your own offers (cancel), and giving a spare card to a friend. Registers the 'collections' feature; its badge counts set rewards and the grand prize waiting. Ships title-seed.json: a small album with sets, a pack and a chest.\",\n \"provides\": [\n \"collection album with sets\",\n \"set rewards and grand prize\",\n \"card packs, once or ten at a time\",\n \"collection chests for points\",\n \"trading cards with friends\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\n \"collection\",\n \"album\",\n \"cards\",\n \"stickers\",\n \"trading\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\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
  }
@@ -29,19 +29,19 @@
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": "a8a8cda19becde2ed4fc12f338866442c3175a8e8ad0650110ca7057896541ab",
40
+ "contentHash": "1637a37f2dc83c83692cd674b2b4c4b5ea959d64ab783bffdb09254e50975836",
41
41
  "files": [
42
42
  {
43
43
  "path": "CoopEventsScreen.tsx",
44
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { ActiveCoopEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Timer,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bulkSpins,\n chainIDs,\n coopBadge,\n eventOf,\n groupRows,\n spinPrices,\n type CoopEventView,\n type CoopSection,\n type GroupView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events (engine CoopEvent): the player joins a group (the server matches partners, bots fill\n// the gaps), each member spins the wheel to build their own object, and the group shares a grand\n// prize when every object is done. The group lives in ONE shared document on the server, so the\n// screen always re-reads it after an action instead of patching it locally.\n\n/** The player's group and the active events, shared by the screen and the badge (one read). */\nexport function createCoopStore(features: FeatureRegistry) {\n let group: GroupView | null | undefined = undefined;\n const listeners = new Set<() => void>();\n return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n const res = await client.coopEvent.getUserState();\n group = res.ok\n ? ((res.data.ActiveGroup as GroupView | null) ?? null)\n : null;\n const rows = groupRows(\n group,\n eventOf(configSection<CoopSection>(client, \"CoopEvent\"), group),\n client.auth.context?.userID,\n );\n features.setBadge(\"coop-events\", group ? coopBadge(rows) : 0);\n for (const l of listeners) l();\n },\n };\n}\nexport type CoopStore = ReturnType<typeof createCoopStore>;\n\nexport function makeCoopEventsScreen(store: CoopStore) {\n return function CoopEventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const group = useSyncExternalStore(store.subscribe, store.get, store.get);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (group === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n return group ? (\n <Group group={group} store={store} />\n ) : (\n <Join store={store} />\n );\n };\n}\n\n// ── Not in a group: the running events to join ───────────────────────────────────────────────\n\nfunction Join({ store }: { store: CoopStore }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const stagger = useStagger();\n const [active, setActive] = useState<ActiveCoopEventInfo[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n const chains = chainIDs(configSection<CoopSection>(client, \"CoopEvent\"));\n void Promise.all(\n chains.map((c) => client.coopEvent.getActiveEvent(c)),\n ).then((all) => setActive(all.flatMap((r) => (r.ok ? [r.data] : []))));\n }, [client]);\n\n const join = async (chainID: string) => {\n setBusy(chainID);\n const res = await client.coopEvent.joinOrCreateGroup(chainID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await store.load(client);\n };\n\n if (!active)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const running = active.filter((a) => a.EventDef);\n if (running.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noEvent\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {running.map((a, i) => {\n const def = a.EventDef as CoopEventView;\n return (\n <Card\n key={a.CoopChainID}\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #2f7d6b 0%, #174a3f 100%)\",\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"friends\" size={48} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(def.DisplayName ?? a.CoopChainID)}\n </div>\n <Timer\n at={a.ComputedEndUtc as string | undefined}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n {def.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {catalog.localize(def.Description)}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList\n lines={groupRows(null, def, null).grandPrize}\n size={20}\n />\n </div>\n <Button\n tone=\"green\"\n size=\"lg\"\n attract\n busy={busy === a.CoopChainID}\n onClick={() => void join(a.CoopChainID)}\n data-tutorial-anchor={`coop:join:${a.CoopChainID}`}\n >\n {t(\"join\")} · {Number(def.PartnerCount ?? 0) + 1} {t(\"players\")}\n </Button>\n </Card>\n );\n })}\n </div>\n );\n}\n\n// ── In a group ───────────────────────────────────────────────────────────────────────────────\n\nfunction Group({\n group,\n store,\n}: {\n group: GroupView;\n store: CoopStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [last, setLast] = useState<number | null>(null);\n const [leaving, setLeaving] = useState(false);\n const event = eventOf(configSection<CoopSection>(client, \"CoopEvent\"), group);\n const rows = groupRows(group, event, client.auth.context?.userID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const reload = useCallback(() => store.load(client), [client]);\n\n const spin = async (optionID: string | null, count: number) => {\n setBusy(`${optionID ?? \"\"}:${count}`);\n const res = await client.coopEvent.spin(\n group.CoopChainID ?? \"\",\n group.GroupID ?? \"\",\n count,\n optionID ?? undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n setLast(Number(res.data.ProgressDelta ?? 0));\n await reload();\n };\n const claim = async (kind: \"object\" | \"grand\") => {\n setBusy(kind);\n const res =\n kind === \"object\"\n ? await client.coopEvent.claimObjectReward(group.GroupID ?? \"\")\n : await client.coopEvent.claimGrandPrize(group.GroupID ?? \"\");\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n grantedBy(res.data),\n kind === \"grand\" ? { sound: \"levelUp\" } : undefined,\n );\n await reload();\n };\n const leave = async () => {\n setLeaving(false);\n const res = await client.coopEvent.leaveGroup(group.GroupID ?? undefined);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await reload();\n };\n\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(event?.DisplayName ?? group.CoopEventID ?? \"\")}\n </div>\n {group.ExpiresAtUtc && !rows.finished ? (\n <Timer at={group.ExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n ) : null}\n {rows.forming ? (\n <div style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"forming\")}\n </div>\n ) : null}\n </div>\n\n <SectionTitle>{t(\"team\")}</SectionTitle>\n {rows.members.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{ display: \"grid\", gap: 6, opacity: m.left ? 0.5 : 1 }}\n >\n <div\n style={{ display: \"flex\", justifyContent: \"space-between\", gap: 8 }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {m.me ? t(\"you\") : m.name}\n {m.bot ? ` · ${t(\"bot\")}` : \"\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(m.objectName)}\n </span>\n </div>\n <ProgressBar\n value={m.progress}\n max={m.max}\n tone={m.done ? \"gold\" : \"green\"}\n />\n </Card>\n ))}\n\n {rows.mine && !rows.mine.done && !rows.finished ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n {last !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n color: v.green,\n textAlign: \"center\",\n }}\n >\n +{last} {t(\"progress\")}\n </div>\n ) : null}\n {spinPrices(event).map((o) => {\n const affordable = canAfford(o.price, balances);\n const bulk = bulkSpins(event);\n return (\n <div key={o.id ?? \"free\"} style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === `${o.id ?? \"\"}:1`}\n onClick={() => void spin(o.id, 1)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"coop:spin\"\n >\n {t(\"spin\")}{\" \"}\n {o.price.length > 0 ? (\n <ResourceList lines={o.price} size={18} />\n ) : null}\n </Button>\n {bulk > 0 ? (\n <Button\n tone=\"blue\"\n disabled={\n !canAfford(\n o.price.map((l) => ({ ...l, amount: l.amount * bulk })),\n balances,\n )\n }\n busy={busy === `${o.id ?? \"\"}:${bulk}`}\n onClick={() => void spin(o.id, bulk)}\n >\n ×{bulk}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n\n {rows.objectRewardReady ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"object\"}\n onClick={() => void claim(\"object\")}\n >\n {t(\"claimObject\")}{\" \"}\n <ResourceList lines={rows.objectReward} size={18} />\n </Button>\n ) : null}\n {rows.grandPrizeReady ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claim(\"grand\")}\n >\n {t(\"claimGrand\")} <ResourceList lines={rows.grandPrize} size={20} />\n </Button>\n ) : null}\n {!rows.grandPrizeReady ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => (rows.finished ? void leave() : setLeaving(true))}\n >\n {rows.finished ? t(\"close\") : t(\"leave\")}\n </Button>\n ) : null}\n {leaving ? (\n <Popup title={t(\"leave\")} onClose={() => setLeaving(false)} width={360}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n <Button tone=\"red\" onClick={() => void leave()}>\n {t(\"leave\")}\n </Button>\n </Popup>\n ) : null}\n </div>\n );\n}\n\n/** Keeps the badge (object reward and grand prize waiting) — only on a title with team events. */\nexport function makeCoopWatcher(store: CoopStore) {\n return function CoopWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n return null;\n };\n}\n"
44
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type { ActiveCoopEventInfo, IDosGamesClient } from \"@idosgames/core\";\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 Center,\n configSection,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n bulkSpins,\n chainIDs,\n coopBadge,\n eventOf,\n groupRows,\n spinPrices,\n type CoopEventView,\n type CoopSection,\n type GroupView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Team events (engine CoopEvent): the player joins a group (the server matches partners, bots fill\n// the gaps), each member spins the wheel to build their own object, and the group shares a grand\n// prize when every object is done. The group lives in ONE shared document on the server, so the\n// screen always re-reads it after an action instead of patching it locally.\n\n/** The player's group and the active events, shared by the screen and the badge (one read). */\nexport function createCoopStore(features: FeatureRegistry) {\n let group: GroupView | null | undefined = undefined;\n const listeners = new Set<() => void>();\n return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n const res = await client.coopEvent.getUserState();\n group = res.ok\n ? ((res.data.ActiveGroup as GroupView | null) ?? null)\n : null;\n const rows = groupRows(\n group,\n eventOf(configSection<CoopSection>(client, \"CoopEvent\"), group),\n client.auth.context?.userID,\n );\n features.setBadge(\"coop-events\", group ? coopBadge(rows) : 0);\n for (const l of listeners) l();\n },\n };\n}\nexport type CoopStore = ReturnType<typeof createCoopStore>;\n\nexport function makeCoopEventsScreen(store: CoopStore) {\n return function CoopEventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const group = useSyncExternalStore(store.subscribe, store.get, store.get);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (group === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n return group ? (\n <Group group={group} store={store} />\n ) : (\n <Join store={store} />\n );\n };\n}\n\n// ── Not in a group: the running events to join ───────────────────────────────────────────────\n\nfunction Join({ store }: { store: CoopStore }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const now = useNow();\n const toast = useToast();\n const stagger = useStagger();\n const [active, setActive] = useState<ActiveCoopEventInfo[] | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n useEffect(() => {\n const chains = chainIDs(configSection<CoopSection>(client, \"CoopEvent\"));\n void Promise.all(\n chains.map((c) => client.coopEvent.getActiveEvent(c)),\n ).then((all) => setActive(all.flatMap((r) => (r.ok ? [r.data] : []))));\n }, [client]);\n\n const join = async (chainID: string) => {\n setBusy(chainID);\n const res = await client.coopEvent.joinOrCreateGroup(chainID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await store.load(client);\n };\n\n if (!active)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const running = active.filter((a) => a.EventDef);\n if (running.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noEvent\")} />;\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {running.map((a, i) => {\n const def = a.EventDef as CoopEventView;\n return (\n <Card\n key={a.CoopChainID}\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"green\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"friends\" size={48} />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(def.DisplayName ?? a.CoopChainID)}\n </div>\n <Timer\n at={a.ComputedEndUtc as string | undefined}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n {def.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n {catalog.localize(def.Description)}\n </div>\n ) : null}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n flexWrap: \"wrap\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"grandPrize\")}\n </span>\n <ResourceList\n lines={groupRows(null, def, null).grandPrize}\n size={20}\n />\n </div>\n <Button\n tone=\"green\"\n size=\"lg\"\n attract\n busy={busy === a.CoopChainID}\n onClick={() => void join(a.CoopChainID)}\n data-tutorial-anchor={`coop:join:${a.CoopChainID}`}\n >\n {t(\"join\")} · {Number(def.PartnerCount ?? 0) + 1} {t(\"players\")}\n </Button>\n </Card>\n );\n })}\n </div>\n );\n}\n\n// ── In a group ───────────────────────────────────────────────────────────────────────────────\n\nfunction Group({\n group,\n store,\n}: {\n group: GroupView;\n store: CoopStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const [last, setLast] = useState<number | null>(null);\n const [leaving, setLeaving] = useState(false);\n const event = eventOf(configSection<CoopSection>(client, \"CoopEvent\"), group);\n const rows = groupRows(group, event, client.auth.context?.userID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const reload = useCallback(() => store.load(client), [client]);\n\n const spin = async (optionID: string | null, count: number) => {\n setBusy(`${optionID ?? \"\"}:${count}`);\n const res = await client.coopEvent.spin(\n group.CoopChainID ?? \"\",\n group.GroupID ?? \"\",\n count,\n optionID ?? undefined,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n setLast(Number(res.data.ProgressDelta ?? 0));\n await reload();\n };\n const claim = async (kind: \"object\" | \"grand\") => {\n setBusy(kind);\n const res =\n kind === \"object\"\n ? await client.coopEvent.claimObjectReward(group.GroupID ?? \"\")\n : await client.coopEvent.claimGrandPrize(group.GroupID ?? \"\");\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(\n grantedBy(res.data),\n kind === \"grand\" ? { sound: \"levelUp\" } : undefined,\n );\n await reload();\n };\n const leave = async () => {\n setLeaving(false);\n const res = await client.coopEvent.leaveGroup(group.GroupID ?? undefined);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n await reload();\n };\n\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 12 }}>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 17 }}>\n {catalog.localize(event?.DisplayName ?? group.CoopEventID ?? \"\")}\n </div>\n {group.ExpiresAtUtc && !rows.finished ? (\n <Timer at={group.ExpiresAtUtc} now={now} label={t(\"endsIn\")} />\n ) : null}\n {rows.forming ? (\n <div style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"forming\")}\n </div>\n ) : null}\n </div>\n\n <SectionTitle>{t(\"team\")}</SectionTitle>\n {rows.members.map((m) => (\n <Card\n key={m.userID}\n highlight={m.me}\n style={{ display: \"grid\", gap: 6, opacity: m.left ? 0.5 : 1 }}\n >\n <div\n style={{ display: \"flex\", justifyContent: \"space-between\", gap: 8 }}\n >\n <span style={{ ...outlined, fontSize: 14 }}>\n {m.me ? t(\"you\") : m.name}\n {m.bot ? ` · ${t(\"bot\")}` : \"\"}\n </span>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(m.objectName)}\n </span>\n </div>\n <ProgressBar\n value={m.progress}\n max={m.max}\n tone={m.done ? \"gold\" : \"green\"}\n />\n </Card>\n ))}\n\n {rows.mine && !rows.mine.done && !rows.finished ? (\n <div style={{ display: \"grid\", gap: 8 }}>\n {last !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n color: v.green,\n textAlign: \"center\",\n }}\n >\n +{last} {t(\"progress\")}\n </div>\n ) : null}\n {spinPrices(event).map((o) => {\n const affordable = canAfford(o.price, balances);\n const bulk = bulkSpins(event);\n return (\n <div key={o.id ?? \"free\"} style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === `${o.id ?? \"\"}:1`}\n onClick={() => void spin(o.id, 1)}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"coop:spin\"\n >\n {t(\"spin\")}{\" \"}\n {o.price.length > 0 ? (\n <ResourceList lines={o.price} size={18} />\n ) : null}\n </Button>\n {bulk > 0 ? (\n <Button\n tone=\"blue\"\n disabled={\n !canAfford(\n o.price.map((l) => ({ ...l, amount: l.amount * bulk })),\n balances,\n )\n }\n busy={busy === `${o.id ?? \"\"}:${bulk}`}\n onClick={() => void spin(o.id, bulk)}\n >\n ×{bulk}\n </Button>\n ) : null}\n </div>\n );\n })}\n </div>\n ) : null}\n\n {rows.objectRewardReady ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"object\"}\n onClick={() => void claim(\"object\")}\n >\n {t(\"claimObject\")}{\" \"}\n <ResourceList lines={rows.objectReward} size={18} />\n </Button>\n ) : null}\n {rows.grandPrizeReady ? (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy === \"grand\"}\n onClick={() => void claim(\"grand\")}\n >\n {t(\"claimGrand\")} <ResourceList lines={rows.grandPrize} size={20} />\n </Button>\n ) : null}\n {!rows.grandPrizeReady ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n onClick={() => (rows.finished ? void leave() : setLeaving(true))}\n >\n {rows.finished ? t(\"close\") : t(\"leave\")}\n </Button>\n ) : null}\n {leaving ? (\n <Popup title={t(\"leave\")} onClose={() => setLeaving(false)} width={360}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n <Button tone=\"red\" onClick={() => void leave()}>\n {t(\"leave\")}\n </Button>\n </Popup>\n ) : null}\n </div>\n );\n}\n\n/** Keeps the badge (object reward and grand prize waiting) — only on a title with team events. */\nexport function makeCoopWatcher(store: CoopStore) {\n return function CoopWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n return null;\n };\n}\n"
45
45
  },
46
46
  {
47
47
  "path": "i18n.ts",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "module.meta.json",
64
- "content": "{\n \"id\": \"coop-events\",\n \"type\": \"feature\",\n \"summary\": \"Team events for any game: join a group, each member spins the wheel to build their own object, the team shares a grand prize.\",\n \"description\": \"A ready team event screen (engine CoopEvent) for ANY game, built on the @idosgames/react/ui kit and client.coopEvent. Not in a group: the running team events of the title with their timers and team prize, 'Join a team' (the server matches partners, helpers fill the gaps). In a group: every member with their object and its progress, 'Spin' (once or five at a time, free or for currency) to build your own object, the object's reward when it is done, the team prize when every object is done, and leaving the team. The group lives in one shared document on the server — the screen re-reads it after every action. Registers the 'coop-events' feature; its badge counts the object reward and the team prize waiting. Ships title-seed.json: a weekly team build event.\",\n \"provides\": [\n \"team event screen\",\n \"join a team (matchmaking by the server)\",\n \"spin to build your object\",\n \"object reward and team prize\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\"coop\", \"team\", \"group\", \"events\", \"social\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
64
+ "content": "{\n \"id\": \"coop-events\",\n \"type\": \"feature\",\n \"summary\": \"Team events for any game: join a group, each member spins the wheel to build their own object, the team shares a grand prize.\",\n \"description\": \"A ready team event screen (engine CoopEvent) for ANY game, built on the @idosgames/react/ui kit and client.coopEvent. Not in a group: the running team events of the title with their timers and team prize, 'Join a team' (the server matches partners, helpers fill the gaps). In a group: every member with their object and its progress, 'Spin' (once or five at a time, free or for currency) to build your own object, the object's reward when it is done, the team prize when every object is done, and leaving the team. The group lives in one shared document on the server — the screen re-reads it after every action. Registers the 'coop-events' feature; its badge counts the object reward and the team prize waiting. Ships title-seed.json: a weekly team build event.\",\n \"provides\": [\n \"team event screen\",\n \"join a team (matchmaking by the server)\",\n \"spin to build your object\",\n \"object reward and team prize\",\n \"badge for rewards waiting\"\n ],\n \"tags\": [\"coop\", \"team\", \"group\", \"events\", \"social\", \"ui\", \"screen\"],\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
  }
@@ -32,19 +32,19 @@
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": "552ef22ea9a8d06c8306a72caa5dc8b86165a197c6a7fa7cb41a4fb39bf55df5",
43
+ "contentHash": "e91d01d089fbe0897748e06bb079be807ad32b921f44c2999b592001c3bd5464",
44
44
  "files": [
45
45
  {
46
46
  "path": "CraftScreen.tsx",
47
- "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n addToContract,\n autoFill,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type CraftSection,\n type Recipe,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft (Unity Alikhan/Craft: recipe cards → contract panel → info popup). The screen is the recipe\n// ladder — \"3 × Common → 1 × Rare\"; a recipe opens its CONTRACT as a popup: the slots to fill, the\n// player's fitting items to tap in (or fill automatically), the ways to pay, what can come out, and\n// \"Craft\". The server rolls the result; it pops out with the reward animation.\n\nexport function makeCraftScreen(features: FeatureRegistry) {\n return function CraftScreen(_: FeatureScreenProps): ReactNode {\n return <Craft features={features} />;\n };\n}\n\nfunction Craft({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const layout = useGameLayout();\n const [, loaded] = useState(0);\n const section = configSection<CraftSection>(client, \"Craft\");\n useEffect(() => {\n if (!section)\n void client.craft.getDefinitions().then(() => loaded((n) => n + 1));\n void client.user.getUserInventory();\n }, [client, section]);\n const list = recipes(section);\n const types = [...new Set(list.map((r) => r.type))];\n const [tab, setTab] = useState<Recipe[\"type\"]>(\"rarity\");\n const [open, setOpen] = useState<Recipe | null>(null);\n const state = useUserState();\n const owned = ownedCounts(state?.InventoryV2 as never);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string }\n >;\n\n if (list.length === 0)\n return <EmptyState glyph=\"hammer\" text={t(\"noRecipes\")} />;\n const current = types.includes(tab) ? tab : types[0]!;\n const shown = list.filter((r) => r.type === current);\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={types.map((x) => ({\n id: x,\n label: x === \"rarity\" ? t(\"byRarity\") : t(\"byCollection\"),\n }))}\n value={current}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns:\n layout === \"phone\"\n ? \"1fr\"\n : \"repeat(auto-fill, minmax(320px, 1fr))\",\n gap: 12,\n }}\n >\n {shown.map((r, i) => {\n const fit = eligibleInputs(r, items, owned).reduce(\n (s, e) => s + e.available,\n 0,\n );\n const ready = fit >= r.count;\n return (\n <Card\n key={r.id}\n highlight={ready}\n onClick={() => setOpen(r)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto 1fr\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <RarityChip rarity={r.input} label={`${r.count} × ${r.input}`} />\n <div className={ready ? \"idos-bounce\" : undefined}>\n <Icon glyph=\"hammer\" size={34} />\n </div>\n <RarityChip rarity={r.output} label={`1 × ${r.output}`} />\n <div\n style={{\n gridColumn: \"1 / -1\",\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"need\")}: {Math.min(fit, r.count)} / {r.count}\n </span>\n <ResourceList lines={r.options[0]?.price ?? []} size={16} />\n </div>\n </Card>\n );\n })}\n </div>\n {open ? (\n <ContractPopup\n recipe={open}\n owned={owned}\n items={items}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction RarityChip({\n rarity,\n label,\n}: {\n rarity: string;\n label: string;\n}): ReactNode {\n const catalog = useCatalog();\n const color = catalog.rarityColor(rarity);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div\n style={{\n width: 58,\n height: 58,\n borderRadius: 14,\n border: `3px solid ${color}`,\n background: `radial-gradient(circle, ${color}55, ${v.panelDeep} 70%)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Icon glyph=\"box\" size={34} />\n </div>\n <span style={{ ...outlined, fontSize: 12, color }}>{label}</span>\n </div>\n );\n}\n\nfunction ContractPopup({\n recipe,\n owned,\n items,\n onShop,\n onClose,\n}: {\n recipe: Recipe;\n owned: Record<string, number>;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n onShop?: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const anvil = useRef<HTMLDivElement>(null);\n const eligible = eligibleInputs(recipe, items, owned);\n const [contract, setContract] = useState<string[]>([]);\n const [optionID, setOptionID] = useState(recipe.options[0]?.id ?? \"\");\n const [busy, setBusy] = useState(false);\n const option = recipe.options.find((o) => o.id === optionID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(option?.price ?? [], balances);\n\n const craft = async () => {\n setBusy(true);\n const started = Date.now();\n const res = await client.craft.craft(recipe.id, contract, 1, option?.id);\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 600 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(anvil.current);\n setContract([]);\n celebrate(grantedBy(res.data), { title: t(\"crafted\"), sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={`${t(\"contract\")}: ${recipe.count} × ${recipe.input} → ${recipe.output}`}\n onClose={onClose}\n width={520}\n >\n <div\n ref={anvil}\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n animation: busy ? \"idos-shake .35s ease-in-out infinite\" : undefined,\n }}\n >\n {Array.from({ length: recipe.count }, (_, i) => {\n const itemID = contract[i];\n return itemID ? (\n <ItemTile\n key={i}\n itemID={itemID}\n size={56}\n onClick={() => setContract((c) => c.filter((_, j) => j !== i))}\n />\n ) : (\n <div\n key={i}\n style={{\n width: 56,\n height: 56,\n borderRadius: 14,\n border: `3px dashed ${v.panelEdge}`,\n background: \"rgba(0,0,0,.25)\",\n }}\n />\n );\n })}\n </div>\n <SectionTitle\n right={\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setContract(autoFill(recipe, eligible))}\n disabled={eligible.length === 0}\n >\n {t(\"auto\")}\n </Button>\n {contract.length > 0 ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setContract([])}>\n {t(\"clear\")}\n </Button>\n ) : null}\n </span>\n }\n >\n <span style={{ fontSize: 15 }}>{t(\"yourItems\")}</span>\n </SectionTitle>\n {eligible.length === 0 ? (\n <EmptyState\n glyph=\"box\"\n text={t(\"noItems\")}\n action={\n onShop ? <Button onClick={onShop}>{t(\"toShop\")}</Button> : undefined\n }\n />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n {eligible.map((e) => {\n const used = contract.filter((x) => x === e.itemID).length;\n return (\n <ItemTile\n key={e.itemID}\n itemID={e.itemID}\n count={e.available - used}\n size={64}\n dim={used >= e.available}\n onClick={() =>\n setContract((c) =>\n addToContract(c, e.itemID, recipe, e.available),\n )\n }\n />\n );\n })}\n </div>\n )}\n <Outputs recipe={recipe} items={items} />\n {recipe.options.length > 1 ? (\n <Tabs\n tabs={recipe.options.map((o) => ({\n id: o.id,\n label: o.name ?? o.id,\n }))}\n value={optionID}\n onChange={setOptionID}\n />\n ) : null}\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={contract.length === recipe.count && affordable}\n disabled={contract.length < recipe.count || !affordable}\n busy={busy}\n onClick={() => void craft()}\n data-tutorial-anchor=\"craft:go\"\n >\n <Icon glyph=\"hammer\" size={22} /> {t(\"craft\")}{\" \"}\n {option && option.price.length > 0 ? (\n <ResourceList\n lines={option.price}\n size={18}\n dimIf={() => !affordable}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n </Popup>\n );\n}\n\nfunction Outputs({\n recipe,\n items,\n}: {\n recipe: Recipe;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n}): ReactNode {\n const outputs = possibleOutputs(recipe, items);\n if (outputs.length === 0) return null;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"canYield\")}\n </div>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 6,\n justifyContent: \"center\",\n }}\n >\n {outputs.map((id) => (\n <ItemTile key={id} itemID={id} size={48} />\n ))}\n </div>\n </div>\n );\n}\n"
47
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n addToContract,\n autoFill,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type CraftSection,\n type Recipe,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft (Unity Alikhan/Craft: recipe cards → contract panel → info popup). The screen is the recipe\n// ladder — \"3 × Common → 1 × Rare\"; a recipe opens its CONTRACT as a popup: the slots to fill, the\n// player's fitting items to tap in (or fill automatically), the ways to pay, what can come out, and\n// \"Craft\". The server rolls the result; it pops out with the reward animation.\n\nexport function makeCraftScreen(features: FeatureRegistry) {\n return function CraftScreen(_: FeatureScreenProps): ReactNode {\n return <Craft features={features} />;\n };\n}\n\nfunction Craft({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const layout = useGameLayout();\n const [, loaded] = useState(0);\n const section = configSection<CraftSection>(client, \"Craft\");\n useEffect(() => {\n if (!section)\n void client.craft.getDefinitions().then(() => loaded((n) => n + 1));\n void client.user.getUserInventory();\n }, [client, section]);\n const list = recipes(section);\n const types = [...new Set(list.map((r) => r.type))];\n const [tab, setTab] = useState<Recipe[\"type\"]>(\"rarity\");\n const [open, setOpen] = useState<Recipe | null>(null);\n const state = useUserState();\n const owned = ownedCounts(state?.InventoryV2 as never);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string }\n >;\n\n if (list.length === 0)\n return <EmptyState glyph=\"hammer\" text={t(\"noRecipes\")} />;\n const current = types.includes(tab) ? tab : types[0]!;\n const shown = list.filter((r) => r.type === current);\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={types.map((x) => ({\n id: x,\n label: x === \"rarity\" ? t(\"byRarity\") : t(\"byCollection\"),\n }))}\n value={current}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns:\n layout === \"phone\"\n ? \"1fr\"\n : \"repeat(auto-fill, minmax(320px, 1fr))\",\n gap: 12,\n }}\n >\n {shown.map((r, i) => {\n const fit = eligibleInputs(r, items, owned).reduce(\n (s, e) => s + e.available,\n 0,\n );\n const ready = fit >= r.count;\n return (\n <Card\n key={r.id}\n highlight={ready}\n onClick={() => setOpen(r)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto 1fr\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <RarityChip rarity={r.input} label={`${r.count} × ${r.input}`} />\n <div className={ready ? \"idos-bounce\" : undefined}>\n <Icon glyph=\"hammer\" size={34} />\n </div>\n <RarityChip rarity={r.output} label={`1 × ${r.output}`} />\n <div\n style={{\n gridColumn: \"1 / -1\",\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"need\")}: {Math.min(fit, r.count)} / {r.count}\n </span>\n <ResourceList lines={r.options[0]?.price ?? []} size={16} />\n </div>\n </Card>\n );\n })}\n </div>\n {open ? (\n <ContractPopup\n recipe={open}\n owned={owned}\n items={items}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction RarityChip({\n rarity,\n label,\n}: {\n rarity: string;\n label: string;\n}): ReactNode {\n const catalog = useCatalog();\n const color = catalog.rarityColor(rarity);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div\n style={{\n width: 58,\n height: 58,\n borderRadius: 14,\n border: `3px solid ${color}`,\n background: `radial-gradient(circle, ${color}55, ${v.panelDeep} 70%)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Icon glyph=\"box\" size={34} />\n </div>\n <span style={{ ...outlined, fontSize: 12, color }}>{label}</span>\n </div>\n );\n}\n\nfunction ContractPopup({\n recipe,\n owned,\n items,\n onShop,\n onClose,\n}: {\n recipe: Recipe;\n owned: Record<string, number>;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n onShop?: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const anvil = useRef<HTMLDivElement>(null);\n const eligible = eligibleInputs(recipe, items, owned);\n const [contract, setContract] = useState<string[]>([]);\n const [optionID, setOptionID] = useState(recipe.options[0]?.id ?? \"\");\n const [busy, setBusy] = useState(false);\n const option = recipe.options.find((o) => o.id === optionID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(option?.price ?? [], balances);\n\n const craft = async () => {\n setBusy(true);\n const started = Date.now();\n const res = await client.craft.craft(recipe.id, contract, 1, option?.id);\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 600 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(anvil.current);\n setContract([]);\n celebrate(grantedBy(res.data), { title: t(\"crafted\"), sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={`${t(\"contract\")}: ${recipe.count} × ${recipe.input} → ${recipe.output}`}\n onClose={onClose}\n width={520}\n >\n <div\n ref={anvil}\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n animation: busy ? \"idos-shake .35s ease-in-out infinite\" : undefined,\n }}\n >\n {Array.from({ length: recipe.count }, (_, i) => {\n const itemID = contract[i];\n return itemID ? (\n <ItemTile\n key={i}\n itemID={itemID}\n size={56}\n onClick={() => setContract((c) => c.filter((_, j) => j !== i))}\n />\n ) : (\n <div\n key={i}\n style={{\n width: 56,\n height: 56,\n borderRadius: 14,\n border: `3px dashed ${v.panelEdge}`,\n background: v.wellSoft,\n }}\n />\n );\n })}\n </div>\n <SectionTitle\n right={\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setContract(autoFill(recipe, eligible))}\n disabled={eligible.length === 0}\n >\n {t(\"auto\")}\n </Button>\n {contract.length > 0 ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setContract([])}>\n {t(\"clear\")}\n </Button>\n ) : null}\n </span>\n }\n >\n <span style={{ fontSize: 15 }}>{t(\"yourItems\")}</span>\n </SectionTitle>\n {eligible.length === 0 ? (\n <EmptyState\n glyph=\"box\"\n text={t(\"noItems\")}\n action={\n onShop ? <Button onClick={onShop}>{t(\"toShop\")}</Button> : undefined\n }\n />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n {eligible.map((e) => {\n const used = contract.filter((x) => x === e.itemID).length;\n return (\n <ItemTile\n key={e.itemID}\n itemID={e.itemID}\n count={e.available - used}\n size={64}\n dim={used >= e.available}\n onClick={() =>\n setContract((c) =>\n addToContract(c, e.itemID, recipe, e.available),\n )\n }\n />\n );\n })}\n </div>\n )}\n <Outputs recipe={recipe} items={items} />\n {recipe.options.length > 1 ? (\n <Tabs\n tabs={recipe.options.map((o) => ({\n id: o.id,\n label: o.name ?? o.id,\n }))}\n value={optionID}\n onChange={setOptionID}\n />\n ) : null}\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={contract.length === recipe.count && affordable}\n disabled={contract.length < recipe.count || !affordable}\n busy={busy}\n onClick={() => void craft()}\n data-tutorial-anchor=\"craft:go\"\n >\n <Icon glyph=\"hammer\" size={22} /> {t(\"craft\")}{\" \"}\n {option && option.price.length > 0 ? (\n <ResourceList\n lines={option.price}\n size={18}\n dimIf={() => !affordable}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n </Popup>\n );\n}\n\nfunction Outputs({\n recipe,\n items,\n}: {\n recipe: Recipe;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n}): ReactNode {\n const outputs = possibleOutputs(recipe, items);\n if (outputs.length === 0) return null;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"canYield\")}\n </div>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 6,\n justifyContent: \"center\",\n }}\n >\n {outputs.map((id) => (\n <ItemTile key={id} itemID={id} size={48} />\n ))}\n </div>\n </div>\n );\n}\n"
48
48
  },
49
49
  {
50
50
  "path": "i18n.ts",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  {
66
66
  "path": "module.meta.json",
67
- "content": "{\n \"id\": \"craft\",\n \"type\": \"feature\",\n \"summary\": \"Craft screen for any game: trade-up recipes by rarity or collection, a contract of owned items, auto-fill, price options and the result popup.\",\n \"description\": \"A ready craft screen (Unity Alikhan/Craft) for ANY game, built on the @idosgames/react/ui kit and client.craft. The screen lists the title's recipes as cards, lowest rarity first: what goes in (N items of a rarity or a collection) and what can come out. A recipe opens as a popup — the contract: the fitting items the player owns (worn equipment does not count), tap to add, 'Auto' fills from the most plentiful, the price options, and 'Craft'. The server rolls the result (only items with a roll Weight > 0 are promised) and the reward popup shows it flying into the inventory. 'Not enough coins' offers to open the shop (ctx.features.open('store')). Registers the 'craft' feature; its badge counts recipes the player can run now. Ships title-seed.json: 3 Common → Rare for coins and 3 Rare → Legendary for gems.\",\n \"provides\": [\n \"craft screen from the title's recipes\",\n \"trade-up by rarity or collection\",\n \"contract popup with owned items\",\n \"auto-fill of the contract\",\n \"price options\",\n \"craft result with reward animation\",\n \"craft badge for runnable recipes\",\n \"demo recipes\"\n ],\n \"tags\": [\"craft\", \"crafting\", \"trade-up\", \"items\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
67
+ "content": "{\n \"id\": \"craft\",\n \"type\": \"feature\",\n \"summary\": \"Craft screen for any game: trade-up recipes by rarity or collection, a contract of owned items, auto-fill, price options and the result popup.\",\n \"description\": \"A ready craft screen (Unity Alikhan/Craft) for ANY game, built on the @idosgames/react/ui kit and client.craft. The screen lists the title's recipes as cards, lowest rarity first: what goes in (N items of a rarity or a collection) and what can come out. A recipe opens as a popup — the contract: the fitting items the player owns (worn equipment does not count), tap to add, 'Auto' fills from the most plentiful, the price options, and 'Craft'. The server rolls the result (only items with a roll Weight > 0 are promised) and the reward popup shows it flying into the inventory. 'Not enough coins' offers to open the shop (ctx.features.open('store')). Registers the 'craft' feature; its badge counts recipes the player can run now. Ships title-seed.json: 3 Common → Rare for coins and 3 Rare → Legendary for gems.\",\n \"provides\": [\n \"craft screen from the title's recipes\",\n \"trade-up by rarity or collection\",\n \"contract popup with owned items\",\n \"auto-fill of the contract\",\n \"price options\",\n \"craft result with reward animation\",\n \"craft badge for runnable recipes\",\n \"demo recipes\"\n ],\n \"tags\": [\"craft\", \"crafting\", \"trade-up\", \"items\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.1\"\n}\n"
68
68
  }
69
69
  ]
70
70
  }
@@ -31,19 +31,19 @@
31
31
  "name": "iDos Games",
32
32
  "url": "https://idosgames.com"
33
33
  },
34
- "version": "0.1.0"
34
+ "version": "0.1.1"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.19.1",
38
- "@idosgames/module-sdk": "0.7.0",
39
- "@idosgames/react": "0.3.0",
37
+ "@idosgames/core": "0.20.0",
38
+ "@idosgames/module-sdk": "0.8.1",
39
+ "@idosgames/react": "0.4.2",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "f4633cc8068e4679f7321ca9353fd633271d877e8e003b73c237a7dcfb5f7e71",
42
+ "contentHash": "105861fcd680e4cd96311f9cc6a3967278e08f1e0512f43be1f439a13a3503a9",
43
43
  "files": [
44
44
  {
45
45
  "path": "DealsScreen.tsx",
46
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type {\n ActiveDealSlotInfo,\n DealNodeDefinition,\n IDosGamesClient,\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 Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Timer,\n canAfford,\n errorText,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeReward,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Special offers (Unity Alikhan/DealOffer): a deal pops up by itself when a new one starts (the\n// impression is recorded), and the \"Offers\" screen lists every active one. A deal opens as a popup\n// shaped by its graph — one big offer, a chain of steps (each unlocks the next), or cards to choose\n// one from — with its milestone bar under it.\n\n/** Active deals, shared by the screen and the auto-popup (one request for both). */\nexport function createDealsStore(features: FeatureRegistry) {\n let slots: ActiveDealSlotInfo[] | null = null;\n const listeners = new Set<() => void>();\n const set = (next: ActiveDealSlotInfo[]) => {\n slots = [...next].sort(\n (a, b) => Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0),\n );\n features.setBadge(\"deals\", dealsBadge(slots));\n for (const l of listeners) l();\n };\n return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n const res = await client.dealOffer.getActiveDeals();\n set(res.ok ? (res.data.Slots ?? []) : []);\n },\n };\n}\nexport type DealsStore = ReturnType<typeof createDealsStore>;\n\nexport function makeDealsScreen(store: DealsStore) {\n return function DealsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const [open, setOpen] = useState<string | null>(null);\n const stagger = useStagger();\n const catalog = useCatalog();\n const now = useNow();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!slots)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (slots.length === 0)\n return <EmptyState glyph=\"offer\" text={t(\"noDeals\")} />;\n const current = slots.find((s) => s.SlotID === open);\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {slots.map((s, i) => (\n <Card\n key={s.SlotID!}\n onClick={() => setOpen(s.SlotID!)}\n style={{\n display: \"grid\",\n gap: 8,\n background: \"linear-gradient(180deg, #7b4be0 0%, #3f2699 100%)\",\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={s.OfferDef?.Identity?.AssetPaths?.icon ?? \"offer\"}\n size={44}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(\n s.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n </div>\n <Timer\n at={s.ComputedExpiresAtUtc}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n <ResourceList lines={offerRewards(s)} size={22} />\n </Card>\n ))}\n {current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n };\n}\n\n/** The deal that starts pops up by itself, once per session per slot; the impression is recorded. */\nexport function makeDealsWatcher(store: DealsStore) {\n return function DealsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const shown = useRef(new Set<string>());\n const [popup, setPopup] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => {\n if (popup || !slots) return;\n const next = slots.find(\n (s) => s.IsNewActivation && s.SlotID && !shown.current.has(s.SlotID),\n );\n if (!next?.SlotID) return;\n shown.current.add(next.SlotID);\n void client.dealOffer.recordShow(next.SlotID);\n setPopup(next.SlotID);\n }, [slots, popup, client]);\n const current = slots?.find((s) => s.SlotID === popup);\n return current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setPopup(null)}\n queued\n />\n ) : null;\n };\n}\n\n/** `queued` — popped up by itself (the watcher): waits for other auto-popups. */\nfunction DealPopup({\n slot,\n store,\n onClose,\n queued,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n onClose: () => void;\n queued?: boolean;\n}): ReactNode {\n const catalog = useCatalog();\n const now = useNow();\n const shape = dealShape(slot);\n const nodes = orderedNodes(slot).filter(\n (n) =>\n !(n.HideWhenCompleted && nodeStatus(slot, n) === \"Completed\") &&\n nodeStatus(slot, n) !== \"Hidden\",\n );\n return (\n <Popup\n title={catalog.localize(\n slot.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n onClose={onClose}\n width={shape === \"choice\" ? 600 : 460}\n variant=\"center\"\n queued={queued}\n >\n {slot.OfferDef?.Identity?.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 14,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(slot.OfferDef.Identity.Description)}\n </div>\n ) : null}\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <Timer\n at={slot.ComputedExpiresAtUtc}\n now={now}\n label={`⏱ ${t(\"endsIn\")}`}\n />\n </div>\n {shape === \"choice\" ? (\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"choose\")}\n </div>\n ) : null}\n <div\n style={\n shape === \"choice\"\n ? {\n display: \"grid\",\n gridTemplateColumns: `repeat(${Math.min(3, nodes.length)}, minmax(0, 1fr))`,\n gap: 10,\n }\n : { display: \"grid\", gap: 10 }\n }\n >\n {nodes.map((node, i) => (\n <NodeCard\n key={node.NodeID ?? i}\n slot={slot}\n node={node}\n index={i}\n shape={shape}\n store={store}\n />\n ))}\n </div>\n <Milestones slot={slot} store={store} />\n </Popup>\n );\n}\n\nfunction NodeCard({\n slot,\n node,\n index,\n shape,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n node: DealNodeDefinition;\n index: number;\n shape: string;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const stagger = useStagger();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const status = nodeStatus(slot, node);\n const kind = nodeKind(node);\n const price = nodePrice(node);\n const reward = nodeReward(node);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(price, balances);\n const store_ = needsStore(node);\n const done = status === \"Completed\";\n const available = status === \"Available\" || status === \"InProgress\";\n\n const execute = async () => {\n setBusy(true);\n const res = await client.dealOffer.executeNode(slot.SlotID!, node.NodeID!);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"purchase\" ? \"purchase\" : \"claim\");\n kit.fx.burst(ref.current);\n const granted = grantedBy(res.data);\n const lines = granted.length > 0 ? granted : reward;\n if (res.data.OfferExhausted) {\n // The finished offer leaves the active list — its bar's reward is taken now or never.\n for (const id of milestonesReachedBy(slot, node)) {\n const bonus = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (bonus.ok) lines.push(...grantedBy(bonus.data));\n }\n close();\n }\n celebrate(lines);\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n opacity: status === \"Locked\" ? 0.55 : 1,\n border: available ? `2px solid ${v.gold}` : panel.border,\n ...stagger(index),\n }}\n >\n {shape === \"chain\" ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"step\")} {index + 1}\n </div>\n ) : null}\n {node.Identity?.DisplayName ? (\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(node.Identity.DisplayName)}\n </div>\n ) : null}\n <ResourceList\n lines={reward}\n size={shape === \"single\" ? 36 : 26}\n gap={12}\n />\n {done ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={22} /> {t(\"done\")}\n </span>\n ) : status === \"Locked\" ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <Icon glyph=\"lock\" size={20} /> {t(\"locked\")}\n </span>\n ) : kind === \"video\" ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"adsUnavailable\")}\n </Button>\n ) : kind === \"info\" ? (\n <Button\n tone=\"blue\"\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n >\n {t(\"gotIt\")}\n </Button>\n ) : store_ ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"storeOnly\")}\n </Button>\n ) : (\n <Button\n tone={kind === \"free\" ? \"green\" : \"gold\"}\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`deal:${node.NodeID}`}\n >\n {kind === \"free\" ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={18} dimIf={() => !affordable} />\n )}\n </Button>\n )}\n </div>\n );\n}\n\nfunction Milestones({\n slot,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const rows = dealMilestones(slot);\n if (rows.length === 0) return null;\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const max = rows[rows.length - 1]?.required ?? 1;\n const claim = async (id: string) => {\n const res = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await store.load(client);\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"milestones\")}\n </div>\n <ProgressBar value={Math.min(earned, max)} max={max} tone=\"gold\" />\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rows.map((m) => (\n <Button\n key={m.id}\n size=\"sm\"\n tone={m.state === \"ready\" ? \"gold\" : \"grey\"}\n attract={m.state === \"ready\"}\n disabled={m.state !== \"ready\"}\n onClick={() => void claim(m.id)}\n >\n {m.state === \"claimed\" ? <Icon glyph=\"check\" size={16} /> : null}{\" \"}\n {m.required} <ResourceList lines={m.reward} size={16} />\n </Button>\n ))}\n </div>\n </div>\n );\n}\n"
46
+ "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type {\n ActiveDealSlotInfo,\n DealNodeDefinition,\n IDosGamesClient,\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 canAfford,\n Card,\n Center,\n EmptyState,\n errorText,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Timer,\n toneSurface,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeReward,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Special offers (Unity Alikhan/DealOffer): a deal pops up by itself when a new one starts (the\n// impression is recorded), and the \"Offers\" screen lists every active one. A deal opens as a popup\n// shaped by its graph — one big offer, a chain of steps (each unlocks the next), or cards to choose\n// one from — with its milestone bar under it.\n\n/** Active deals, shared by the screen and the auto-popup (one request for both). */\nexport function createDealsStore(features: FeatureRegistry) {\n let slots: ActiveDealSlotInfo[] | null = null;\n const listeners = new Set<() => void>();\n const set = (next: ActiveDealSlotInfo[]) => {\n slots = [...next].sort(\n (a, b) => Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0),\n );\n features.setBadge(\"deals\", dealsBadge(slots));\n for (const l of listeners) l();\n };\n return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n const res = await client.dealOffer.getActiveDeals();\n set(res.ok ? (res.data.Slots ?? []) : []);\n },\n };\n}\nexport type DealsStore = ReturnType<typeof createDealsStore>;\n\nexport function makeDealsScreen(store: DealsStore) {\n return function DealsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const [open, setOpen] = useState<string | null>(null);\n const stagger = useStagger();\n const catalog = useCatalog();\n const now = useNow();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!slots)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (slots.length === 0)\n return <EmptyState glyph=\"offer\" text={t(\"noDeals\")} />;\n const current = slots.find((s) => s.SlotID === open);\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {slots.map((s, i) => (\n <Card\n key={s.SlotID!}\n onClick={() => setOpen(s.SlotID!)}\n style={{\n display: \"grid\",\n gap: 8,\n ...toneSurface(\"blue\"),\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={s.OfferDef?.Identity?.AssetPaths?.icon ?? \"offer\"}\n size={44}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(\n s.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n </div>\n <Timer\n at={s.ComputedExpiresAtUtc}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n <ResourceList lines={offerRewards(s)} size={22} />\n </Card>\n ))}\n {current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n };\n}\n\n/** The deal that starts pops up by itself, once per session per slot; the impression is recorded. */\nexport function makeDealsWatcher(store: DealsStore) {\n return function DealsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const shown = useRef(new Set<string>());\n const [popup, setPopup] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => {\n if (popup || !slots) return;\n const next = slots.find(\n (s) => s.IsNewActivation && s.SlotID && !shown.current.has(s.SlotID),\n );\n if (!next?.SlotID) return;\n shown.current.add(next.SlotID);\n void client.dealOffer.recordShow(next.SlotID);\n setPopup(next.SlotID);\n }, [slots, popup, client]);\n const current = slots?.find((s) => s.SlotID === popup);\n return current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setPopup(null)}\n queued\n />\n ) : null;\n };\n}\n\n/** `queued` — popped up by itself (the watcher): waits for other auto-popups. */\nfunction DealPopup({\n slot,\n store,\n onClose,\n queued,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n onClose: () => void;\n queued?: boolean;\n}): ReactNode {\n const catalog = useCatalog();\n const now = useNow();\n const shape = dealShape(slot);\n const nodes = orderedNodes(slot).filter(\n (n) =>\n !(n.HideWhenCompleted && nodeStatus(slot, n) === \"Completed\") &&\n nodeStatus(slot, n) !== \"Hidden\",\n );\n return (\n <Popup\n title={catalog.localize(\n slot.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n onClose={onClose}\n width={shape === \"choice\" ? 600 : 460}\n variant=\"center\"\n queued={queued}\n >\n {slot.OfferDef?.Identity?.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 14,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(slot.OfferDef.Identity.Description)}\n </div>\n ) : null}\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <Timer\n at={slot.ComputedExpiresAtUtc}\n now={now}\n label={`⏱ ${t(\"endsIn\")}`}\n />\n </div>\n {shape === \"choice\" ? (\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"choose\")}\n </div>\n ) : null}\n <div\n style={\n shape === \"choice\"\n ? {\n display: \"grid\",\n gridTemplateColumns: `repeat(${Math.min(3, nodes.length)}, minmax(0, 1fr))`,\n gap: 10,\n }\n : { display: \"grid\", gap: 10 }\n }\n >\n {nodes.map((node, i) => (\n <NodeCard\n key={node.NodeID ?? i}\n slot={slot}\n node={node}\n index={i}\n shape={shape}\n store={store}\n />\n ))}\n </div>\n <Milestones slot={slot} store={store} />\n </Popup>\n );\n}\n\nfunction NodeCard({\n slot,\n node,\n index,\n shape,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n node: DealNodeDefinition;\n index: number;\n shape: string;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const stagger = useStagger();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const status = nodeStatus(slot, node);\n const kind = nodeKind(node);\n const price = nodePrice(node);\n const reward = nodeReward(node);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(price, balances);\n const store_ = needsStore(node);\n const done = status === \"Completed\";\n const available = status === \"Available\" || status === \"InProgress\";\n\n const execute = async () => {\n setBusy(true);\n const res = await client.dealOffer.executeNode(slot.SlotID!, node.NodeID!);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"purchase\" ? \"purchase\" : \"claim\");\n kit.fx.burst(ref.current);\n const granted = grantedBy(res.data);\n const lines = granted.length > 0 ? granted : reward;\n if (res.data.OfferExhausted) {\n // The finished offer leaves the active list — its bar's reward is taken now or never.\n for (const id of milestonesReachedBy(slot, node)) {\n const bonus = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (bonus.ok) lines.push(...grantedBy(bonus.data));\n }\n close();\n }\n celebrate(lines);\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n opacity: status === \"Locked\" ? 0.55 : 1,\n border: available ? `2px solid ${v.gold}` : panel.border,\n ...stagger(index),\n }}\n >\n {shape === \"chain\" ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"step\")} {index + 1}\n </div>\n ) : null}\n {node.Identity?.DisplayName ? (\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(node.Identity.DisplayName)}\n </div>\n ) : null}\n <ResourceList\n lines={reward}\n size={shape === \"single\" ? 36 : 26}\n gap={12}\n />\n {done ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={22} /> {t(\"done\")}\n </span>\n ) : status === \"Locked\" ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <Icon glyph=\"lock\" size={20} /> {t(\"locked\")}\n </span>\n ) : kind === \"video\" ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"adsUnavailable\")}\n </Button>\n ) : kind === \"info\" ? (\n <Button\n tone=\"blue\"\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n >\n {t(\"gotIt\")}\n </Button>\n ) : store_ ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"storeOnly\")}\n </Button>\n ) : (\n <Button\n tone={kind === \"free\" ? \"green\" : \"gold\"}\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`deal:${node.NodeID}`}\n >\n {kind === \"free\" ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={18} dimIf={() => !affordable} />\n )}\n </Button>\n )}\n </div>\n );\n}\n\nfunction Milestones({\n slot,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const rows = dealMilestones(slot);\n if (rows.length === 0) return null;\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const max = rows[rows.length - 1]?.required ?? 1;\n const claim = async (id: string) => {\n const res = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await store.load(client);\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"milestones\")}\n </div>\n <ProgressBar value={Math.min(earned, max)} max={max} tone=\"gold\" />\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rows.map((m) => (\n <Button\n key={m.id}\n size=\"sm\"\n tone={m.state === \"ready\" ? \"gold\" : \"grey\"}\n attract={m.state === \"ready\"}\n disabled={m.state !== \"ready\"}\n onClick={() => void claim(m.id)}\n >\n {m.state === \"claimed\" ? <Icon glyph=\"check\" size={16} /> : null}{\" \"}\n {m.required} <ResourceList lines={m.reward} size={16} />\n </Button>\n ))}\n </div>\n </div>\n );\n}\n"
47
47
  },
48
48
  {
49
49
  "path": "i18n.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
65
  "path": "module.meta.json",
66
- "content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"summary\": \"Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.\",\n \"description\": \"Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.\",\n \"provides\": [\n \"special offers screen\",\n \"auto-popup of a new deal\",\n \"single, chain and pick-one offers\",\n \"offer milestones\",\n \"deals badge\",\n \"demo offers\"\n ],\n \"tags\": [\n \"offers\",\n \"deals\",\n \"starter-pack\",\n \"monetization\",\n \"popup\",\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"
66
+ "content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"summary\": \"Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.\",\n \"description\": \"Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.\",\n \"provides\": [\n \"special offers screen\",\n \"auto-popup of a new deal\",\n \"single, chain and pick-one offers\",\n \"offer milestones\",\n \"deals badge\",\n \"demo offers\"\n ],\n \"tags\": [\n \"offers\",\n \"deals\",\n \"starter-pack\",\n \"monetization\",\n \"popup\",\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"
67
67
  }
68
68
  ]
69
69
  }