@idosgames/mcp 0.1.28 → 0.1.29

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 (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +11 -71
  4. package/registry/index.json +109 -109
  5. package/registry/modules/ai.json +4 -4
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +6 -6
  8. package/registry/modules/character.json +33 -5
  9. package/registry/modules/chat.json +25 -5
  10. package/registry/modules/collections.json +22 -6
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +6 -6
  13. package/registry/modules/deals.json +2 -2
  14. package/registry/modules/events.json +5 -5
  15. package/registry/modules/friends.json +25 -5
  16. package/registry/modules/idle-rpg.json +2 -2
  17. package/registry/modules/inventory.json +2 -2
  18. package/registry/modules/invite.json +5 -5
  19. package/registry/modules/leaderboard.json +21 -5
  20. package/registry/modules/lootboxes.json +6 -6
  21. package/registry/modules/mailbox.json +33 -5
  22. package/registry/modules/marketplace.json +34 -6
  23. package/registry/modules/mini-game.json +2 -2
  24. package/registry/modules/multiplayer.json +34 -10
  25. package/registry/modules/premium.json +6 -6
  26. package/registry/modules/profile.json +4 -4
  27. package/registry/modules/pvp.json +33 -5
  28. package/registry/modules/quests.json +5 -5
  29. package/registry/modules/rewards.json +6 -6
  30. package/registry/modules/season.json +5 -5
  31. package/registry/modules/store.json +4 -4
  32. package/registry/modules/tutorials.json +2 -2
  33. package/registry/modules/voxelcraft.json +264 -12
  34. package/registry/modules/workshop.json +49 -5
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/blockchain-system.json +1 -1
  37. package/registry/skills/checkout-system.json +1 -1
  38. package/registry/skills/cloud-code.json +2 -2
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +1 -1
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/skills/idosgames-project-structure.json +1 -1
  44. package/registry/skills/idosgames-ui-kit.json +2 -2
  45. package/registry/skills/marketplace-system.json +1 -1
  46. package/registry/skills/match-system.json +1 -1
  47. package/registry/skills/multiplayer-realtime.json +2 -2
  48. package/registry/skills/voxelcraft-worlds.json +2 -2
  49. package/registry/skills/workshop-system.json +2 -2
@@ -35,16 +35,16 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.2",
38
+ "@idosgames/core": "0.21.3",
39
39
  "@idosgames/module-sdk": "0.10.0",
40
- "@idosgames/react": "0.4.4",
40
+ "@idosgames/react": "0.4.5",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "6c4adf3a2a0ac70e26cc90e3c7996d982d869a7fd1583439e6409db1fe7063d5",
43
+ "contentHash": "ab2815b81025407fcdcaa04e6a18b852646e2f19553390a9bd3f5ca153cee4fc",
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.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"
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 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(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",
@@ -56,11 +56,11 @@
56
56
  },
57
57
  {
58
58
  "path": "model.ts",
59
- "content": "// Pure helpers of the craft screen (Unity Alikhan/Craft): recipes, which owned items fit a recipe's\n// contract, auto-fill, and what the recipe can yield. The server rolls the result — this only\n// decides what the player can put in. Covered by model.test.ts.\n\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\nexport interface RecipeView {\n CraftID?: string;\n Type?: string | null;\n CatalogID?: string | null;\n CollectionID?: string | null;\n InputRarityID?: string | null;\n OutputRarityID?: string | null;\n RequiredItemCount?: number | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n DisplayName?: string | null;\n}\n\nexport interface CraftSection {\n Definitions?: Record<string, RecipeView | null> | null;\n}\n\nexport interface Recipe {\n id: string;\n type: \"rarity\" | \"collection\";\n catalogID: string | null;\n collectionID: string | null;\n input: string;\n output: string;\n count: number;\n options: Array<{ id: string; name: string | null; price: ResourceLine[] }>;\n}\n\nexport function recipes(section: CraftSection | undefined): Recipe[] {\n return (\n Object.entries(section?.Definitions ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => ({\n id: r!.CraftID ?? id,\n type: (r!.Type === \"TradeUpCollection\"\n ? \"collection\"\n : \"rarity\") as Recipe[\"type\"],\n catalogID: r!.CatalogID ?? null,\n collectionID: r!.CollectionID ?? null,\n input: r!.InputRarityID ?? \"Common\",\n output: r!.OutputRarityID ?? \"Rare\",\n count: Math.max(1, Number(r!.RequiredItemCount ?? 10)),\n options: Object.entries(r!.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o!.OptionID || oid,\n name: o!.Name ?? null,\n price: costLines(o!.Cost as never),\n })),\n }))\n // Lowest input rarity first — the ladder reads bottom-up.\n .sort(\n (a, b) =>\n rarityRank(b.input) - rarityRank(a.input) || a.id.localeCompare(b.id),\n )\n );\n}\n\ntype Def = ItemDefinitionView & {\n Metadata?: { RarityID?: string | null; CollectionID?: string | null } | null;\n CatalogID?: string;\n Weight?: number | null;\n};\n\nfunction matches(\n recipe: Recipe,\n def: Def | undefined,\n rarity: string,\n): boolean {\n if (!def || def.Metadata?.RarityID !== rarity) return false;\n if (recipe.catalogID && def.CatalogID && def.CatalogID !== recipe.catalogID)\n return false;\n if (\n recipe.type === \"collection\" &&\n def.Metadata?.CollectionID !== recipe.collectionID\n )\n return false;\n return true;\n}\n\n/** Items the recipe accepts, with how many the player can put in (equipped copies do not count). */\nexport function eligibleInputs(\n recipe: Recipe,\n items: Map<string, Def>,\n owned: Record<string, number>,\n): Array<{ itemID: string; available: number }> {\n return [...items.entries()]\n .filter(\n ([id, def]) => matches(recipe, def, recipe.input) && (owned[id] ?? 0) > 0,\n )\n .map(([itemID]) => ({ itemID, available: owned[itemID] ?? 0 }))\n .sort(\n (a, b) => b.available - a.available || a.itemID.localeCompare(b.itemID),\n );\n}\n\n/** What the recipe can roll: items of the output rarity (and collection) with a roll weight — the\n * engine never rolls an item whose Weight is 0 (the default). */\nexport function possibleOutputs(\n recipe: Recipe,\n items: Map<string, Def>,\n): string[] {\n return [...items.entries()]\n .filter(\n ([, def]) =>\n matches(recipe, def, recipe.output) && Number(def.Weight ?? 0) > 0,\n )\n .map(([id]) => id)\n .sort();\n}\n\n/** Fills the contract from the most plentiful items first (Unity \"auto\" button). */\nexport function autoFill(\n recipe: Recipe,\n eligible: Array<{ itemID: string; available: number }>,\n): string[] {\n const out: string[] = [];\n for (const e of eligible) {\n for (let i = 0; i < e.available && out.length < recipe.count; i++)\n out.push(e.itemID);\n if (out.length >= recipe.count) break;\n }\n return out;\n}\n\n/** Adds one copy of an item to the contract if it fits and the player has another copy. */\nexport function addToContract(\n contract: string[],\n itemID: string,\n recipe: Recipe,\n available: number,\n): string[] {\n if (contract.length >= recipe.count) return contract;\n if (contract.filter((x) => x === itemID).length >= available) return contract;\n return [...contract, itemID];\n}\n\n/** Owned counts by item id (stackable + unstackable totals minus equipped instances). */\nexport function ownedCounts(\n inventory:\n | {\n Items?: Record<\n string,\n {\n TotalAmount?: number;\n StackableAmount?: number;\n UnstackableAmount?: number;\n } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; EquippedSlot?: unknown } | null\n > | null;\n }\n | null\n | undefined,\n): Record<string, number> {\n const out: Record<string, number> = {};\n for (const [id, t] of Object.entries(inventory?.Items ?? {})) {\n out[id] = Number(\n t?.TotalAmount ?? (t?.StackableAmount ?? 0) + (t?.UnstackableAmount ?? 0),\n );\n }\n for (const inst of Object.values(inventory?.UnstackableItems ?? {})) {\n if (inst?.ItemID && inst.EquippedSlot)\n out[inst.ItemID] = Math.max(0, (out[inst.ItemID] ?? 0) - 1);\n }\n return out;\n}\n\n/** Badge: recipes the player could run right now (enough fitting items, something to roll). */\nexport function craftableRecipes(\n list: Recipe[],\n items: Map<string, Def>,\n owned: Record<string, number>,\n): number {\n return list.filter(\n (r) =>\n possibleOutputs(r, items).length > 0 &&\n eligibleInputs(r, items, owned).reduce((s, e) => s + e.available, 0) >=\n r.count,\n ).length;\n}\n\n/** Whether the title has craft recipes — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: CraftSection | undefined): boolean {\n return Object.keys(section?.Definitions ?? {}).length > 0;\n}\n"
59
+ "content": "// Pure helpers of the craft screen (Unity Alikhan/Craft): recipes, which owned items fit a recipe's\n// contract, auto-fill, and what the recipe can yield. The server rolls the result — this only\n// decides what the player can put in. Covered by model.test.ts.\n\nimport type { CraftDefinitions } from \"@idosgames/core\";\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\nexport interface Recipe {\n id: string;\n type: \"rarity\" | \"collection\";\n catalogID: string | null;\n collectionID: string | null;\n input: string;\n output: string;\n count: number;\n options: Array<{ id: string; name: string | null; price: ResourceLine[] }>;\n}\n\nexport function recipes(section: CraftDefinitions | undefined): Recipe[] {\n return (\n Object.entries(section?.Definitions ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => ({\n id: r!.CraftID ?? id,\n type: (r!.Type === \"TradeUpCollection\"\n ? \"collection\"\n : \"rarity\") as Recipe[\"type\"],\n catalogID: r!.CatalogID ?? null,\n collectionID: r!.CollectionID ?? null,\n input: r!.InputRarityID ?? \"Common\",\n output: r!.OutputRarityID ?? \"Rare\",\n count: Math.max(1, Number(r!.RequiredItemCount ?? 10)),\n options: Object.entries(r!.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => ({\n id: o!.OptionID || oid,\n name: o!.Name ?? null,\n price: costLines(o!.Cost as never),\n })),\n }))\n // Lowest input rarity first — the ladder reads bottom-up.\n .sort(\n (a, b) =>\n rarityRank(b.input) - rarityRank(a.input) || a.id.localeCompare(b.id),\n )\n );\n}\n\ntype Def = ItemDefinitionView & {\n Metadata?: { RarityID?: string | null; CollectionID?: string | null } | null;\n CatalogID?: string;\n Weight?: number | null;\n};\n\nfunction matches(\n recipe: Recipe,\n def: Def | undefined,\n rarity: string,\n): boolean {\n if (!def || def.Metadata?.RarityID !== rarity) return false;\n if (recipe.catalogID && def.CatalogID && def.CatalogID !== recipe.catalogID)\n return false;\n if (\n recipe.type === \"collection\" &&\n def.Metadata?.CollectionID !== recipe.collectionID\n )\n return false;\n return true;\n}\n\n/** Items the recipe accepts, with how many the player can put in (equipped copies do not count). */\nexport function eligibleInputs(\n recipe: Recipe,\n items: Map<string, Def>,\n owned: Record<string, number>,\n): Array<{ itemID: string; available: number }> {\n return [...items.entries()]\n .filter(\n ([id, def]) => matches(recipe, def, recipe.input) && (owned[id] ?? 0) > 0,\n )\n .map(([itemID]) => ({ itemID, available: owned[itemID] ?? 0 }))\n .sort(\n (a, b) => b.available - a.available || a.itemID.localeCompare(b.itemID),\n );\n}\n\n/** What the recipe can roll: items of the output rarity (and collection) with a roll weight — the\n * engine never rolls an item whose Weight is 0 (the default). */\nexport function possibleOutputs(\n recipe: Recipe,\n items: Map<string, Def>,\n): string[] {\n return [...items.entries()]\n .filter(\n ([, def]) =>\n matches(recipe, def, recipe.output) && Number(def.Weight ?? 0) > 0,\n )\n .map(([id]) => id)\n .sort();\n}\n\n/** Fills the contract from the most plentiful items first (Unity \"auto\" button). */\nexport function autoFill(\n recipe: Recipe,\n eligible: Array<{ itemID: string; available: number }>,\n): string[] {\n const out: string[] = [];\n for (const e of eligible) {\n for (let i = 0; i < e.available && out.length < recipe.count; i++)\n out.push(e.itemID);\n if (out.length >= recipe.count) break;\n }\n return out;\n}\n\n/** Adds one copy of an item to the contract if it fits and the player has another copy. */\nexport function addToContract(\n contract: string[],\n itemID: string,\n recipe: Recipe,\n available: number,\n): string[] {\n if (contract.length >= recipe.count) return contract;\n if (contract.filter((x) => x === itemID).length >= available) return contract;\n return [...contract, itemID];\n}\n\n/** Owned counts by item id (stackable + unstackable totals minus equipped instances). */\nexport function ownedCounts(\n inventory:\n | {\n Items?: Record<\n string,\n {\n TotalAmount?: number;\n StackableAmount?: number;\n UnstackableAmount?: number;\n } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; EquippedSlot?: unknown } | null\n > | null;\n }\n | null\n | undefined,\n): Record<string, number> {\n const out: Record<string, number> = {};\n for (const [id, t] of Object.entries(inventory?.Items ?? {})) {\n out[id] = Number(\n t?.TotalAmount ?? (t?.StackableAmount ?? 0) + (t?.UnstackableAmount ?? 0),\n );\n }\n for (const inst of Object.values(inventory?.UnstackableItems ?? {})) {\n if (inst?.ItemID && inst.EquippedSlot)\n out[inst.ItemID] = Math.max(0, (out[inst.ItemID] ?? 0) - 1);\n }\n return out;\n}\n\n/** Badge: recipes the player could run right now (enough fitting items, something to roll). */\nexport function craftableRecipes(\n list: Recipe[],\n items: Map<string, Def>,\n owned: Record<string, number>,\n): number {\n return list.filter(\n (r) =>\n possibleOutputs(r, items).length > 0 &&\n eligibleInputs(r, items, owned).reduce((s, e) => s + e.available, 0) >=\n r.count,\n ).length;\n}\n\n/** Whether the title has craft recipes — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: CraftDefinitions | undefined): boolean {\n return Object.keys(section?.Definitions ?? {}).length > 0;\n}\n"
60
60
  },
61
61
  {
62
62
  "path": "module.ts",
63
- "content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { makeCraftScreen } from \"./CraftScreen\";\nimport {\n craftableRecipes,\n hasTitleData,\n ownedCounts,\n recipes,\n type CraftSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft as a feature of the game. Badge: recipes the player has enough fitting items for.\nexport const craftModule: Module = defineModule({\n id: \"craft\",\n meta: { name: \"Craft\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"craft\",\n label: t(\"title\"),\n icon: \"hammer\",\n group: \"economy\",\n order: 60,\n available: false,\n Screen: makeCraftScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"craft\",\n hasTitleData(configSection<CraftSection>(ctx.client, \"Craft\")),\n \"no craft recipes on the title (Craft definitions are empty)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"craft\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function CraftBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const list = recipes(configSection<CraftSection>(client, \"Craft\"));\n const items = itemDefinitions(\n configSection<ItemSection>(client, \"Item\"),\n );\n features.setBadge(\n \"craft\",\n craftableRecipes(\n list,\n items,\n ownedCounts(client.data.user.state?.InventoryV2 as never),\n ),\n );\n };\n update();\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
63
+ "content": "import {\n defineModule,\n type Module,\n type ModuleContext,\n} from \"@idosgames/module-sdk\";\nimport {\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { makeCraftScreen } from \"./CraftScreen\";\nimport { craftableRecipes, hasTitleData, ownedCounts, recipes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft as a feature of the game. Badge: recipes the player has enough fitting items for.\nexport const craftModule: Module = defineModule({\n id: \"craft\",\n meta: { name: \"Craft\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"craft\",\n label: t(\"title\"),\n icon: \"hammer\",\n group: \"economy\",\n order: 60,\n available: false,\n Screen: makeCraftScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"craft\",\n hasTitleData(configSection(ctx.client, \"Craft\")),\n \"no craft recipes on the title (Craft definitions are empty)\",\n );\n watchBadge(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"craft\" }),\n describeActions: {},\n });\n },\n});\n\nfunction watchBadge(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () => {\n const list = recipes(configSection(client, \"Craft\"));\n const items = itemDefinitions(configSection<ItemSection>(client, \"Item\"));\n features.setBadge(\n \"craft\",\n craftableRecipes(\n list,\n items,\n ownedCounts(client.data.user.state?.InventoryV2 as never),\n ),\n );\n };\n update();\n return client.on(\"user:anyUpdated\", update);\n });\n}\n"
64
64
  },
65
65
  {
66
66
  "path": "module.meta.json",
@@ -34,9 +34,9 @@
34
34
  "version": "0.1.1"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.21.2",
37
+ "@idosgames/core": "0.21.3",
38
38
  "@idosgames/module-sdk": "0.10.0",
39
- "@idosgames/react": "0.4.4",
39
+ "@idosgames/react": "0.4.5",
40
40
  "react": "19.2.7"
41
41
  },
42
42
  "contentHash": "34c7958b619d72ec381217931e4cfcd97981d88eebed82ca84450212a73ddabb",
@@ -37,16 +37,16 @@
37
37
  "version": "0.1.1"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.21.2",
40
+ "@idosgames/core": "0.21.3",
41
41
  "@idosgames/module-sdk": "0.10.0",
42
- "@idosgames/react": "0.4.4",
42
+ "@idosgames/react": "0.4.5",
43
43
  "react": "19.2.7"
44
44
  },
45
- "contentHash": "ba7d13114443f6b3d80101b158f287917cb2fc712dd66b9d98c462b9a6fb2c98",
45
+ "contentHash": "499dc307a1e24051a6abaeb717cfea77f546b5fca9fed63162fb87bcac3dccc3",
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 // 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"
49
+ "content": "import type { ModuleContext } from \"@idosgames/module-sdk\";\nimport {\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 watchEvents(ctx: ModuleContext, store: EventsStore): void {\n const { client } = ctx;\n ctx.effect(() => {\n ctx.every(5 * 60_000, () => void store.load(client));\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 if (soon) clearTimeout(soon);\n for (const off of offs) off();\n };\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",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  {
64
64
  "path": "module.ts",
65
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n makeEventsWatcher,\n} from \"./EventsScreen\";\nimport { t } from \"./i18n\";\n\n// Limited-time events as a feature of the game. The always-on watcher keeps the badge current\n// (milestones and VIP top-ups waiting); the screen shows every active event as a tab.\nexport const eventsModule: Module = defineModule({\n id: \"events\",\n meta: { name: \"Events\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createEventsStore(ctx.features);\n ctx.features.register({\n id: \"events\",\n label: t(\"title\"),\n icon: \"flame\",\n group: \"events\",\n order: 25,\n available: false,\n Screen: makeEventsScreen(store),\n });\n // The tab shows while an event is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"events\",\n (store.get()?.length ?? 0) > 0,\n \"no active event right now (TimedEvent)\",\n ),\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeEventsWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"events\",\n active: store.get()?.map((e) => e.TimedEventID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
65
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n watchEvents,\n} from \"./EventsScreen\";\nimport { t } from \"./i18n\";\n\n// Limited-time events as a feature of the game. The always-on watcher keeps the badge current\n// (milestones and VIP top-ups waiting); the screen shows every active event as a tab.\nexport const eventsModule: Module = defineModule({\n id: \"events\",\n meta: { name: \"Events\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createEventsStore(ctx.features);\n ctx.features.register({\n id: \"events\",\n label: t(\"title\"),\n icon: \"flame\",\n group: \"events\",\n order: 25,\n available: false,\n Screen: makeEventsScreen(store),\n });\n // The tab shows while an event is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"events\",\n (store.get()?.length ?? 0) > 0,\n \"no active event right now (TimedEvent)\",\n ),\n );\n watchEvents(ctx, store);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"events\",\n active: store.get()?.map((e) => e.TimedEventID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
66
66
  },
67
67
  {
68
68
  "path": "module.meta.json",
@@ -35,16 +35,36 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.2",
38
+ "@idosgames/core": "0.21.3",
39
39
  "@idosgames/module-sdk": "0.10.0",
40
- "@idosgames/react": "0.4.4",
40
+ "@idosgames/react": "0.4.5",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "baae3cd9af04f1f2286605348c8eed3570add2eec30357f0b0188d32a63cbe3d",
43
+ "contentHash": "4b3ddb96d86ba59db3b42e035156e78e7442009cdd7a996b684ee95a3a64f3e1",
44
44
  "files": [
45
+ {
46
+ "path": "components/AddFriendPopup.tsx",
47
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n errorText,\n outlined,\n Popup,\n TextInput,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { cleanUserID } from \"../model\";\nimport { t } from \"../i18n\";\n\nexport function AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <TextInput\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{ fontFamily: \"monospace\" }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n"
48
+ },
49
+ {
50
+ "path": "components/Inbox.tsx",
51
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Center,\n EmptyState,\n Icon,\n outlined,\n panel,\n Spinner,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport { actorsText, inboxKind, type InboxKind } from \"../model\";\nimport { t } from \"../i18n\";\n\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\n\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nexport function Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n"
52
+ },
53
+ {
54
+ "path": "components/lists.tsx",
55
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n errorText,\n Icon,\n outlined,\n Spinner,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { sortByName, type ProfileView } from \"../model\";\nimport { t } from \"../i18n\";\nimport { PlayerRow } from \"./players\";\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nexport function FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nexport function Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nexport function Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n"
56
+ },
57
+ {
58
+ "path": "components/MyID.tsx",
59
+ "content": "import { type ReactNode } from \"react\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"../i18n\";\n\nexport function MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n"
60
+ },
61
+ {
62
+ "path": "components/players.tsx",
63
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { type FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n ConfirmPopup,\n errorText,\n formatAmount,\n Icon,\n outlined,\n panel,\n Popup,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport { canRemove, displayName, initial, type ProfileView } from \"../model\";\nimport { t } from \"../i18n\";\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nexport function PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nexport function PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
64
+ },
45
65
  {
46
66
  "path": "FriendsScreen.tsx",
47
- "content": "import { useCallback, useEffect, 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 Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // Subscribed directly, not keyed on the state object: the cache patches the social counters in\n // place, and an effect that does not re-run leaves the badge lit after a request is answered.\n let unread = 0;\n const update = () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n client.data.user.state?.Social as\n { IncomingCount?: number } | undefined,\n unread,\n ),\n );\n const setUnread = (n: number) => {\n unread = n;\n update();\n };\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n update();\n load();\n const id = setInterval(load, 5 * 60_000);\n const offs = [\n client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n ),\n client.on(\"user:anyUpdated\", update),\n ];\n return () => {\n clearInterval(id);\n for (const off of offs) off();\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
67
+ "content": "import {\n type ModuleContext,\n type FeatureRegistry,\n type FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { Button, Icon, Tabs } from \"@idosgames/react/ui\";\nimport { friendsBadge, type ProfileView } from \"./model\";\nimport { t } from \"./i18n\";\nimport { AddFriendPopup } from \"./components/AddFriendPopup\";\nimport { Inbox } from \"./components/Inbox\";\nimport { MyID } from \"./components/MyID\";\nimport { FriendList, Requests, Suggested } from \"./components/lists\";\nimport { PlayerCard } from \"./components/players\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function watchFriends(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n // Subscribed directly, not keyed on the state object: the cache patches the social counters in\n // place, and an effect that does not re-run leaves the badge lit after a request is answered.\n let unread = 0;\n const update = () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n client.data.user.state?.Social as\n { IncomingCount?: number } | undefined,\n unread,\n ),\n );\n const setUnread = (n: number) => {\n unread = n;\n update();\n };\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n update();\n ctx.every(5 * 60_000, load);\n const offs = [\n client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n ),\n client.on(\"user:anyUpdated\", update),\n ];\n return () => {\n for (const off of offs) off();\n };\n });\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n"
48
68
  },
49
69
  {
50
70
  "path": "i18n.ts",
@@ -60,7 +80,7 @@
60
80
  },
61
81
  {
62
82
  "path": "module.ts",
63
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeFriendsScreen, makeFriendsWatcher } from \"./FriendsScreen\";\nimport { t } from \"./i18n\";\n\n// Friends as a feature of the game. The always-on watcher keeps the badge: requests waiting for an\n// answer (a counter that comes with the player state) and unread notifications.\nexport const friendsModule: Module = defineModule({\n id: \"friends\",\n meta: { name: \"Friends\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"friends\",\n label: t(\"title\"),\n icon: \"friends\",\n group: \"social\",\n order: 80,\n Screen: makeFriendsScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeFriendsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"friends\" }),\n describeActions: {},\n });\n },\n});\n"
83
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeFriendsScreen, watchFriends } from \"./FriendsScreen\";\nimport { t } from \"./i18n\";\n\n// Friends as a feature of the game. The always-on watcher keeps the badge: requests waiting for an\n// answer (a counter that comes with the player state) and unread notifications.\nexport const friendsModule: Module = defineModule({\n id: \"friends\",\n meta: { name: \"Friends\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"friends\",\n label: t(\"title\"),\n icon: \"friends\",\n group: \"social\",\n order: 80,\n Screen: makeFriendsScreen(ctx.features),\n });\n watchFriends(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"friends\" }),\n describeActions: {},\n });\n },\n});\n"
64
84
  },
65
85
  {
66
86
  "path": "module.meta.json",
@@ -72,9 +72,9 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@idosgames/core": "0.21.2",
75
+ "@idosgames/core": "0.21.3",
76
76
  "@idosgames/module-sdk": "0.10.0",
77
- "@idosgames/react": "0.4.4",
77
+ "@idosgames/react": "0.4.5",
78
78
  "phaser": "4.2.1",
79
79
  "react": "19.2.7"
80
80
  },
@@ -32,9 +32,9 @@
32
32
  "version": "0.1.0"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.21.2",
35
+ "@idosgames/core": "0.21.3",
36
36
  "@idosgames/module-sdk": "0.10.0",
37
- "@idosgames/react": "0.4.4",
37
+ "@idosgames/react": "0.4.5",
38
38
  "react": "19.2.7"
39
39
  },
40
40
  "contentHash": "71f41012c37839b7d4e94c6ed3759118990408da9615743bd27282ca137e49ab",
@@ -35,12 +35,12 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.21.2",
38
+ "@idosgames/core": "0.21.3",
39
39
  "@idosgames/module-sdk": "0.10.0",
40
- "@idosgames/react": "0.4.4",
40
+ "@idosgames/react": "0.4.5",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "249258bc510a42f88eb82c8c63c1d31c9def54246f9856f549b99358e2f6fe04",
43
+ "contentHash": "1b9a0ec5de8392d578afb600c6a4edf4d38e651571cdb63c91bfdf0dc9ddbdb4",
44
44
  "files": [
45
45
  {
46
46
  "path": "i18n.ts",
@@ -56,11 +56,11 @@
56
56
  },
57
57
  {
58
58
  "path": "module.ts",
59
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes — on a title with invites on.\nexport const inviteModule: Module = defineModule({\n id: \"invite\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n // Always in the lobby (owner's decision 29.09.2026); with invites off the screen says so.\n const enabled = hasTitleData(\n configSection<{ IsEnabled?: boolean | null }>(ctx.client, \"Referral\"),\n );\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n Screen: makeReferralScreen(enabled),\n });\n // The badge reads the referral definitions — only where invites are on. On a title without them\n // that read was one more request in the startup burst, and the engine's per-player limit\n // answered it 429.\n if (enabled)\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
59
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeReferralScreen, watchReferral } from \"./ReferralScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes — on a title with invites on.\nexport const inviteModule: Module = defineModule({\n id: \"invite\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n // Always in the lobby (owner's decision 29.09.2026); with invites off the screen says so.\n const enabled = hasTitleData(\n configSection<{ IsEnabled?: boolean | null }>(ctx.client, \"Referral\"),\n );\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n Screen: makeReferralScreen(enabled),\n });\n // The badge reads the referral definitions — only where invites are on. On a title without them\n // that read was one more request in the startup burst, and the engine's per-player limit\n // answered it 429.\n if (enabled) watchReferral(ctx);\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
60
60
  },
61
61
  {
62
62
  "path": "ReferralScreen.tsx",
63
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function makeReferralWatcher(features: FeatureRegistry) {\n return function ReferralWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const defs = useRef<ReferralDefinitions | null>(null);\n useEffect(() => {\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs.current) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.current.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs.current = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs.current) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n }, [client]);\n return null;\n };\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\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.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\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 = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
63
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nimport type { FeatureScreenProps, ModuleContext } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n errorText,\n formatAmount,\n grantedBy,\n grantLines,\n Icon,\n outlined,\n panel,\n plural,\n Popup,\n ResourceList,\n Spinner,\n TextInput,\n toneSurface,\n useCelebrate,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n inviteRows,\n normalizeCode,\n prettyCode,\n referralBadge,\n type InviteRow,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends (Unity V2/Runtime/UI/Referral): the player's own code and link to share, how many\n// friends came by it and the reward ladder for them. Entering a friend's code is a popup (it pays\n// the newcomer's gift). Most players arrive bound already — the SDK sends ?ref= with the login.\n\ntype ReferralState = {\n Code?: string | null;\n SubscribedToUserID?: string | null;\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n};\n\n/** Badge on login: invite rewards ready (definitions + state are one read each, once). */\nexport function watchReferral(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n let defs: ReferralDefinitions | null = null;\n // Subscribed directly: the cache patches the referral state in place (a claimed invite reward),\n // so an effect keyed on the state object would not re-run and the badge would stay lit.\n const update = () => {\n if (!defs) return;\n features.setBadge(\n \"invite\",\n referralBadge(\n inviteRows(\n defs.InviteRewards,\n client.data.user.state?.Referral as ReferralState | undefined,\n ),\n ),\n );\n };\n const off = client.on(\"user:anyUpdated\", update);\n void client.referral.getDefinitions().then((r) => {\n defs = r.ok ? (r.data.ReferralDefinitions ?? null) : null;\n if (defs) update();\n else features.setBadge(\"invite\", 0);\n });\n return off;\n });\n}\n\n/**\n * `enabled` — whether the title has invites on (Referral.IsEnabled). The tab is always in the lobby;\n * with invites off the screen says so and makes no request (the referral reads mint a code).\n */\nexport function makeReferralScreen(enabled: boolean) {\n return function ReferralScreen(_: FeatureScreenProps): ReactNode {\n return enabled ? (\n <Referral />\n ) : (\n <EmptyState glyph=\"link\" text={t(\"disabled\")} />\n );\n };\n}\n\nfunction Referral(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const [defs, setDefs] = useState<ReferralDefinitions | null | undefined>(\n undefined,\n );\n const [me, setMe] = useState<ReferralState | null>(null);\n const [url, setUrl] = useState<string | null>(null);\n const [entering, setEntering] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n\n // getUserState is not a pure read: it mints the code lazily and settles a pending gift —\n // so it runs every time the screen opens.\n const load = useCallback(async () => {\n const [d, s] = await Promise.all([\n client.referral.getDefinitions(),\n client.referral.getUserState(),\n ]);\n setDefs(d.ok ? (d.data.ReferralDefinitions ?? null) : null);\n if (s.ok) {\n setMe((s.data.Referral as ReferralState | null) ?? null);\n setUrl(s.data.InviteUrl ?? null);\n }\n }, [client]);\n useEffect(() => {\n void load();\n }, [load]);\n\n if (defs === undefined)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs || !defs.IsEnabled)\n return <EmptyState glyph=\"link\" text={t(\"disabled\")} />;\n\n const rows = inviteRows(defs.InviteRewards, me);\n const ready = rows.filter((r) => r.state === \"ready\");\n\n const copy = (text: string) =>\n void navigator.clipboard?.writeText(text).then(() => toast(t(\"copied\")));\n const share = async () => {\n const link = url ?? \"\";\n const text = `${t(\"inviteText\")} ${prettyCode(me?.Code)}`;\n if (navigator.share) {\n try {\n await navigator.share({ text, url: link || undefined });\n return;\n } catch {\n // The player closed the share sheet — fall back to copying.\n }\n }\n copy(link ? `${text} ${link}` : text);\n };\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.referral.claimInviteRewardsBatch(\n ready.map((r) => r.id),\n );\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : ready.flatMap((r) => r.reward));\n await load();\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n ...toneSurface(\"green\"),\n }}\n >\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"yourCode\")}</div>\n <div\n className=\"idos-bounce\"\n style={{\n ...outlined,\n fontSize: 34,\n letterSpacing: 3,\n fontFamily: \"monospace\",\n }}\n >\n {me?.Code ? prettyCode(me.Code) : \"…\"}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button\n size=\"sm\"\n tone=\"blue\"\n disabled={!me?.Code}\n onClick={() => me?.Code && copy(me.Code)}\n >\n <Icon glyph=\"copy\" size={16} /> {t(\"copyCode\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n disabled={!me?.Code}\n onClick={() => void share()}\n data-tutorial-anchor=\"invite:share\"\n >\n <Icon glyph=\"link\" size={16} /> {t(\"share\")}\n </Button>\n </div>\n </Card>\n\n <div\n style={{\n ...panel,\n padding: 12,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"friends\" size={30} />\n <div style={{ flex: 1, ...outlined, fontSize: 14 }}>{t(\"joined\")}</div>\n <div style={{ ...outlined, fontSize: 24, color: v.gold }}>\n {formatAmount(Number(me?.FollowersCount ?? 0))}\n </div>\n </div>\n\n {rows.length > 0 ? (\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"rewards\")}\n </div>\n ) : null}\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <InviteCard\n key={row.id}\n row={row}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {me?.SubscribedToUserID ? (\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.green,\n }}\n >\n <Icon glyph=\"check\" size={20} /> {t(\"youCameBy\")}\n </div>\n ) : (\n <Button\n tone=\"blue\"\n onClick={() => setEntering(true)}\n data-tutorial-anchor=\"invite:enter\"\n >\n {t(\"enterCode\")}\n </Button>\n )}\n {entering ? (\n <EnterCodePopup\n defs={defs}\n onClose={() => setEntering(false)}\n onDone={load}\n />\n ) : null}\n </div>\n );\n}\n\nfunction InviteCard({\n row,\n onClaimed,\n style,\n}: {\n row: InviteRow;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claim = async () => {\n setBusy(true);\n const res = await client.referral.claimInviteReward(row.id);\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 = grantedBy(res.data);\n celebrate(got.length > 0 ? got : row.reward);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: row.state === \"ready\" ? `2px solid ${v.gold}` : panel.border,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 52 }}>\n <Icon glyph={row.state === \"locked\" ? \"lock\" : \"friends\"} size={24} />\n <span style={{ ...outlined, fontSize: 12 }}>\n {row.required} {plural(row.required, t(\"friends\"))}\n </span>\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 4 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {row.name}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n </div>\n {row.state === \"ready\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction EnterCodePopup({\n defs,\n onClose,\n onDone,\n}: {\n defs: ReferralDefinitions;\n onClose: () => void;\n onDone: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const code = normalizeCode(value);\n const gift = grantLines(defs.ActivationReward);\n const activate = async () => {\n if (!code) return;\n setBusy(true);\n const res = await client.referral.activateReferralCode(code);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"activated\"), \"success\");\n const got = grantedBy(res.data);\n if (res.data.IsFirstActivation) celebrate(got.length > 0 ? got : gift);\n await onDone();\n onClose();\n };\n return (\n <Popup title={t(\"enterCode\")} onClose={onClose} width={420}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"enterHint\")}\n </div>\n {gift.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"youGet\")}\n </div>\n <ResourceList lines={gift} size={32} gap={12} />\n </div>\n ) : null}\n <TextInput\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder=\"WDJB-MJHT\"\n autoFocus\n maxLength={12}\n onKeyDown={(e) => e.key === \"Enter\" && void activate()}\n style={{\n fontSize: 22,\n letterSpacing: 3,\n textAlign: \"center\",\n textTransform: \"uppercase\",\n fontFamily: \"monospace\",\n }}\n />\n {value && !code ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"badCode\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!code}\n busy={busy}\n onClick={() => void activate()}\n >\n {t(\"activate\")}\n </Button>\n </Popup>\n );\n}\n"
64
64
  },
65
65
  {
66
66
  "path": "module.meta.json",