@idosgames/mcp 0.1.22 → 0.1.23

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 (40) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +2 -2
  3. package/registry/index.json +122 -122
  4. package/registry/modules/ai.json +5 -5
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/boosts.json +7 -7
  7. package/registry/modules/character.json +6 -6
  8. package/registry/modules/chat.json +3 -3
  9. package/registry/modules/collections.json +5 -5
  10. package/registry/modules/coop-events.json +5 -5
  11. package/registry/modules/craft.json +5 -5
  12. package/registry/modules/deals.json +5 -5
  13. package/registry/modules/events.json +5 -5
  14. package/registry/modules/friends.json +5 -5
  15. package/registry/modules/idle-rpg.json +8 -8
  16. package/registry/modules/inventory.json +5 -5
  17. package/registry/modules/invite.json +5 -5
  18. package/registry/modules/leaderboard.json +5 -5
  19. package/registry/modules/lootboxes.json +6 -6
  20. package/registry/modules/mailbox.json +5 -5
  21. package/registry/modules/marketplace.json +6 -6
  22. package/registry/modules/mini-game.json +3 -3
  23. package/registry/modules/multiplayer.json +7 -7
  24. package/registry/modules/premium.json +5 -5
  25. package/registry/modules/profile.json +3 -3
  26. package/registry/modules/pvp.json +7 -7
  27. package/registry/modules/quests.json +5 -5
  28. package/registry/modules/rewards.json +5 -5
  29. package/registry/modules/season.json +5 -5
  30. package/registry/modules/store.json +5 -5
  31. package/registry/modules/tutorials.json +3 -3
  32. package/registry/modules/voxelcraft.json +6 -6
  33. package/registry/modules/workshop.json +3 -3
  34. package/registry/skills/blockchain-system.json +1 -1
  35. package/registry/skills/community-marketing-system.json +1 -1
  36. package/registry/skills/idosgames-getting-started.json +1 -1
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/mailbox-system.json +1 -1
  39. package/registry/skills/match-system.json +2 -2
  40. package/registry/skills/multiplayer-realtime.json +2 -2
@@ -34,16 +34,16 @@
34
34
  "version": "0.1.1"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.20.0",
38
- "@idosgames/module-sdk": "0.8.1",
39
- "@idosgames/react": "0.4.2",
37
+ "@idosgames/core": "0.21.0",
38
+ "@idosgames/module-sdk": "0.8.2",
39
+ "@idosgames/react": "0.4.3",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "35feb5ba1e12924999427f0d56b56a5b9db239ee8f91d805bcf0c39b40349273",
42
+ "contentHash": "04ecd67d6caa7dedcaa260db3f589667b291335c6dd5f5a57797204841c3396d",
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 : 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"
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 // The album is in the login state and every reply keeps it fresh; opening the screen re-checks it\n // through the central cache (a trade a friend accepted, the active collection changing).\n const reload = useCallback(async () => {\n await client.cache.ensureState([\"Collection\"], { maxAgeMs: 60_000 });\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 useEffect(() => {\n // Subscribed directly, not keyed on the state object: the cache may patch it in place, and an\n // effect that does not re-run leaves the badge lit after the reward is taken.\n const update = () => {\n const mine = (client.data.user.state?.Collection ??\n 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 };\n update();\n const off = client.on(\"user:anyUpdated\", update);\n // From the login state; the server only when the active collection may have changed.\n void client.cache.ensureState([\"Collection\"]);\n return off;\n }, [client]);\n return null;\n };\n}\n"
47
47
  },
48
48
  {
49
49
  "path": "i18n.ts",
@@ -32,16 +32,16 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.20.0",
36
- "@idosgames/module-sdk": "0.8.1",
37
- "@idosgames/react": "0.4.2",
35
+ "@idosgames/core": "0.21.0",
36
+ "@idosgames/module-sdk": "0.8.2",
37
+ "@idosgames/react": "0.4.3",
38
38
  "react": "19.2.7"
39
39
  },
40
- "contentHash": "1637a37f2dc83c83692cd674b2b4c4b5ea959d64ab783bffdb09254e50975836",
40
+ "contentHash": "ab28150d10234d119e4c757f55cc4fa9dbf6760ef75c8c6933538272d8f69669",
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 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"
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 // One read at a time; a load asked for while one is in flight runs again right after it. Overlapping\n // reads (the screen, the badge, the re-read after a spin) used to be skipped by the transport as\n // duplicates, and a skipped read turned the player's group into \"no group\" — the join screen.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.coopEvent.getUserState();\n // A failed read keeps what is on screen; only the very first one ends the spinner.\n if (res.ok) group = (res.data.ActiveGroup as GroupView | null) ?? null;\n else if (group === undefined) group = 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 return {\n get: () => group,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\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",
@@ -35,16 +35,16 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.20.0",
39
- "@idosgames/module-sdk": "0.8.1",
40
- "@idosgames/react": "0.4.2",
38
+ "@idosgames/core": "0.21.0",
39
+ "@idosgames/module-sdk": "0.8.2",
40
+ "@idosgames/react": "0.4.3",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "e91d01d089fbe0897748e06bb079be807ad32b921f44c2999b592001c3bd5464",
43
+ "contentHash": "6c4adf3a2a0ac70e26cc90e3c7996d982d869a7fd1583439e6409db1fe7063d5",
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: 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"
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.cache.ensureState([\"InventoryV2\"], { maxAgeMs: 60_000 });\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",
@@ -34,16 +34,16 @@
34
34
  "version": "0.1.1"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.20.0",
38
- "@idosgames/module-sdk": "0.8.1",
39
- "@idosgames/react": "0.4.2",
37
+ "@idosgames/core": "0.21.0",
38
+ "@idosgames/module-sdk": "0.8.2",
39
+ "@idosgames/react": "0.4.3",
40
40
  "react": "19.2.7"
41
41
  },
42
- "contentHash": "105861fcd680e4cd96311f9cc6a3967278e08f1e0512f43be1f439a13a3503a9",
42
+ "contentHash": "34c7958b619d72ec381217931e4cfcd97981d88eebed82ca84450212a73ddabb",
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 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"
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 // One read at a time; a load asked for while one is in flight runs again right after it. The\n // screen, the auto-popup and the reload after a purchase used to overlap — the transport skipped\n // the second as a duplicate, which emptied the list and lost the fresh read after the action.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.dealOffer.getActiveDeals();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) set(res.data.Slots ?? []);\n else if (slots === null) set([]);\n };\n return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\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",
@@ -37,16 +37,16 @@
37
37
  "version": "0.1.1"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.20.0",
41
- "@idosgames/module-sdk": "0.8.1",
42
- "@idosgames/react": "0.4.2",
40
+ "@idosgames/core": "0.21.0",
41
+ "@idosgames/module-sdk": "0.8.2",
42
+ "@idosgames/react": "0.4.3",
43
43
  "react": "19.2.7"
44
44
  },
45
- "contentHash": "b0f644b550581b65f3d48122c732575e8e4e01bb0e1cfe4fcd22830704dae5e2",
45
+ "contentHash": "ba7d13114443f6b3d80101b158f287917cb2fc712dd66b9d98c462b9a6fb2c98",
46
46
  "files": [
47
47
  {
48
48
  "path": "EventsScreen.tsx",
49
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, 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 errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\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 claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\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(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"red\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
49
+ "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, 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 errorText,\n formatAmount,\n grantedBy,\n Icon,\n outlined,\n panel,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\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 claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n // One read at a time; a load asked for while one is in flight runs again right after it. The\n // screen, the watcher and the reload after a claim used to overlap — the transport skipped the\n // second as a duplicate, which wiped the list and lost the fresh read after the claim.\n let loading: Promise<void> | null = null;\n let again = false;\n const readOnce = async (client: IDosGamesClient) => {\n const res = await client.timedEvent.getActiveEvents();\n // A failed read keeps what is on screen; only the very first one ends the spinner empty.\n if (res.ok) events = res.data.ActiveEvents ?? [];\n else events ??= [];\n badge();\n for (const l of listeners) l();\n };\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n if (loading) {\n again = true;\n return loading;\n }\n loading = (async () => {\n do {\n again = false;\n await readOnce(client);\n } while (again);\n })().finally(() => {\n loading = null;\n });\n return loading;\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/**\n * Keeps the badge current: loads the events once, then every few minutes; re-reads soon after the\n * player earns event tokens (a milestone may have been reached) and re-counts on premium changes.\n */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n let soon: ReturnType<typeof setTimeout> | null = null;\n const offs = [\n client.on(\"user:eventTokenUpdated\", () => {\n if (!store.get()?.length) return; // no event running — nothing to re-read\n if (soon) clearTimeout(soon);\n soon = setTimeout(() => void store.load(client), 2_000);\n }),\n // Subscribed directly: the premium state may be patched in place.\n client.on(\"user:anyUpdated\", () => store.refreshBadge()),\n ];\n return () => {\n clearInterval(id);\n if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\n }, [client]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n ...toneSurface(\"red\"),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: v.textDim,\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
50
50
  },
51
51
  {
52
52
  "path": "i18n.ts",