@idosgames/mcp 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +74 -22
- package/registry/index.json +761 -115
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/character.json +75 -0
- package/registry/modules/chat.json +71 -0
- package/registry/modules/craft.json +71 -0
- package/registry/modules/deals.json +70 -0
- package/registry/modules/events.json +77 -0
- package/registry/modules/friends.json +70 -0
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +70 -0
- package/registry/modules/invite.json +70 -0
- package/registry/modules/leaderboard.json +72 -0
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/profile.json +71 -0
- package/registry/modules/pvp.json +69 -0
- package/registry/modules/rewards.json +72 -0
- package/registry/modules/season.json +76 -0
- package/registry/modules/store.json +72 -0
- package/registry/modules/tutorials.json +68 -0
- package/registry/modules/voxelcraft.json +65 -464
- package/registry/modules/workshop.json +13 -8
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/game-hud.json +0 -99
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "craft",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Craft",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"summary": "Craft screen for any game: trade-up recipes by rarity or collection, a contract of owned items, auto-fill, price options and the result popup.",
|
|
12
|
+
"description": "A ready craft screen (Unity Alikhan/Craft) for ANY game, built on the @idosgames/react/ui kit and client.craft. The screen lists the title's recipes as cards, lowest rarity first: what goes in (N items of a rarity or a collection) and what can come out. A recipe opens as a popup — the contract: the fitting items the player owns (worn equipment does not count), tap to add, 'Auto' fills from the most plentiful, the price options, and 'Craft'. The server rolls the result (only items with a roll Weight > 0 are promised) and the reward popup shows it flying into the inventory. 'Not enough coins' offers to open the shop (ctx.features.open('store')). Registers the 'craft' feature; its badge counts recipes the player can run now. Ships title-seed.json: 3 Common → Rare for coins and 3 Rare → Legendary for gems.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"craft screen from the title's recipes",
|
|
15
|
+
"trade-up by rarity or collection",
|
|
16
|
+
"contract popup with owned items",
|
|
17
|
+
"auto-fill of the contract",
|
|
18
|
+
"price options",
|
|
19
|
+
"craft result with reward animation",
|
|
20
|
+
"craft badge for runnable recipes",
|
|
21
|
+
"demo recipes"
|
|
22
|
+
],
|
|
23
|
+
"tags": [
|
|
24
|
+
"craft",
|
|
25
|
+
"crafting",
|
|
26
|
+
"trade-up",
|
|
27
|
+
"items",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen",
|
|
30
|
+
"economy"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "iDos Games",
|
|
34
|
+
"url": "https://idosgames.com"
|
|
35
|
+
},
|
|
36
|
+
"version": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@idosgames/core": "0.19.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
41
|
+
"@idosgames/react": "0.3.0",
|
|
42
|
+
"react": "19.2.7"
|
|
43
|
+
},
|
|
44
|
+
"contentHash": "771cc072c7bf80a5f8c6822836ed3c4198d4572aa5bc7bea663add134d5e79ee",
|
|
45
|
+
"files": [
|
|
46
|
+
{
|
|
47
|
+
"path": "CraftScreen.tsx",
|
|
48
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n addToContract,\n autoFill,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type CraftSection,\n type Recipe,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft (Unity Alikhan/Craft: recipe cards → contract panel → info popup). The screen is the recipe\n// ladder — \"3 × Common → 1 × Rare\"; a recipe opens its CONTRACT as a popup: the slots to fill, the\n// player's fitting items to tap in (or fill automatically), the ways to pay, what can come out, and\n// \"Craft\". The server rolls the result; it pops out with the reward animation.\n\nexport function makeCraftScreen(features: FeatureRegistry) {\n return function CraftScreen(_: FeatureScreenProps): ReactNode {\n return <Craft features={features} />;\n };\n}\n\nfunction Craft({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const layout = useGameLayout();\n const [, loaded] = useState(0);\n const section = configSection<CraftSection>(client, \"Craft\");\n useEffect(() => {\n if (!section)\n void client.craft.getDefinitions().then(() => loaded((n) => n + 1));\n void client.user.getUserInventory();\n }, [client, section]);\n const list = recipes(section);\n const types = [...new Set(list.map((r) => r.type))];\n const [tab, setTab] = useState<Recipe[\"type\"]>(\"rarity\");\n const [open, setOpen] = useState<Recipe | null>(null);\n const state = useUserState();\n const owned = ownedCounts(state?.InventoryV2 as never);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string }\n >;\n\n if (list.length === 0)\n return <EmptyState glyph=\"hammer\" text={t(\"noRecipes\")} />;\n const current = types.includes(tab) ? tab : types[0]!;\n const shown = list.filter((r) => r.type === current);\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={types.map((x) => ({\n id: x,\n label: x === \"rarity\" ? t(\"byRarity\") : t(\"byCollection\"),\n }))}\n value={current}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns:\n layout === \"phone\"\n ? \"1fr\"\n : \"repeat(auto-fill, minmax(320px, 1fr))\",\n gap: 12,\n }}\n >\n {shown.map((r, i) => {\n const fit = eligibleInputs(r, items, owned).reduce(\n (s, e) => s + e.available,\n 0,\n );\n const ready = fit >= r.count;\n return (\n <Card\n key={r.id}\n highlight={ready}\n onClick={() => setOpen(r)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto 1fr\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <RarityChip rarity={r.input} label={`${r.count} × ${r.input}`} />\n <div className={ready ? \"idos-bounce\" : undefined}>\n <Icon glyph=\"hammer\" size={34} />\n </div>\n <RarityChip rarity={r.output} label={`1 × ${r.output}`} />\n <div\n style={{\n gridColumn: \"1 / -1\",\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"need\")}: {Math.min(fit, r.count)} / {r.count}\n </span>\n <ResourceList lines={r.options[0]?.price ?? []} size={16} />\n </div>\n </Card>\n );\n })}\n </div>\n {open ? (\n <ContractPopup\n recipe={open}\n owned={owned}\n items={items}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction RarityChip({\n rarity,\n label,\n}: {\n rarity: string;\n label: string;\n}): ReactNode {\n const catalog = useCatalog();\n const color = catalog.rarityColor(rarity);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div\n style={{\n width: 58,\n height: 58,\n borderRadius: 14,\n border: `3px solid ${color}`,\n background: `radial-gradient(circle, ${color}55, ${v.panelDeep} 70%)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Icon glyph=\"box\" size={34} />\n </div>\n <span style={{ ...outlined, fontSize: 12, color }}>{label}</span>\n </div>\n );\n}\n\nfunction ContractPopup({\n recipe,\n owned,\n items,\n onShop,\n onClose,\n}: {\n recipe: Recipe;\n owned: Record<string, number>;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n onShop?: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const anvil = useRef<HTMLDivElement>(null);\n const eligible = eligibleInputs(recipe, items, owned);\n const [contract, setContract] = useState<string[]>([]);\n const [optionID, setOptionID] = useState(recipe.options[0]?.id ?? \"\");\n const [busy, setBusy] = useState(false);\n const option = recipe.options.find((o) => o.id === optionID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(option?.price ?? [], balances);\n\n const craft = async () => {\n setBusy(true);\n const started = Date.now();\n const res = await client.craft.craft(recipe.id, contract, 1, option?.id);\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 600 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(anvil.current);\n setContract([]);\n celebrate(grantedBy(res.data), { title: t(\"crafted\"), sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={`${t(\"contract\")}: ${recipe.count} × ${recipe.input} → ${recipe.output}`}\n onClose={onClose}\n width={520}\n >\n <div\n ref={anvil}\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n animation: busy ? \"idos-shake .35s ease-in-out infinite\" : undefined,\n }}\n >\n {Array.from({ length: recipe.count }, (_, i) => {\n const itemID = contract[i];\n return itemID ? (\n <ItemTile\n key={i}\n itemID={itemID}\n size={56}\n onClick={() => setContract((c) => c.filter((_, j) => j !== i))}\n />\n ) : (\n <div\n key={i}\n style={{\n width: 56,\n height: 56,\n borderRadius: 14,\n border: `3px dashed ${v.panelEdge}`,\n background: \"rgba(0,0,0,.25)\",\n }}\n />\n );\n })}\n </div>\n <SectionTitle\n right={\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setContract(autoFill(recipe, eligible))}\n disabled={eligible.length === 0}\n >\n {t(\"auto\")}\n </Button>\n {contract.length > 0 ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setContract([])}>\n {t(\"clear\")}\n </Button>\n ) : null}\n </span>\n }\n >\n <span style={{ fontSize: 15 }}>{t(\"yourItems\")}</span>\n </SectionTitle>\n {eligible.length === 0 ? (\n <EmptyState\n glyph=\"box\"\n text={t(\"noItems\")}\n action={\n onShop ? <Button onClick={onShop}>{t(\"toShop\")}</Button> : undefined\n }\n />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n {eligible.map((e) => {\n const used = contract.filter((x) => x === e.itemID).length;\n return (\n <ItemTile\n key={e.itemID}\n itemID={e.itemID}\n count={e.available - used}\n size={64}\n dim={used >= e.available}\n onClick={() =>\n setContract((c) =>\n addToContract(c, e.itemID, recipe, e.available),\n )\n }\n />\n );\n })}\n </div>\n )}\n <Outputs recipe={recipe} items={items} />\n {recipe.options.length > 1 ? (\n <Tabs\n tabs={recipe.options.map((o) => ({\n id: o.id,\n label: o.name ?? o.id,\n }))}\n value={optionID}\n onChange={setOptionID}\n />\n ) : null}\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={contract.length === recipe.count && affordable}\n disabled={contract.length < recipe.count || !affordable}\n busy={busy}\n onClick={() => void craft()}\n data-tutorial-anchor=\"craft:go\"\n >\n <Icon glyph=\"hammer\" size={22} /> {t(\"craft\")}{\" \"}\n {option && option.price.length > 0 ? (\n <ResourceList\n lines={option.price}\n size={18}\n dimIf={() => !affordable}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n </Popup>\n );\n}\n\nfunction Outputs({\n recipe,\n items,\n}: {\n recipe: Recipe;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n}): ReactNode {\n const outputs = possibleOutputs(recipe, items);\n if (outputs.length === 0) return null;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"canYield\")}\n </div>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 6,\n justifyContent: \"center\",\n }}\n >\n {outputs.map((id) => (\n <ItemTile key={id} itemID={id} size={48} />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "i18n.ts",
|
|
52
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Craft\",\n byRarity: \"By rarity\",\n byCollection: \"By collection\",\n tradeUp: \"Trade up\",\n items: \"items\",\n contract: \"Contract\",\n yourItems: \"Your fitting items\",\n auto: \"Fill automatically\",\n clear: \"Clear\",\n craft: \"Craft\",\n free: \"Free\",\n canYield: \"Can yield\",\n noRecipes: \"No recipes in this game yet. Add them in LiveOps → Craft.\",\n noItems: \"You have no fitting items — get some in the shop or from chests.\",\n crafted: \"Crafted!\",\n need: \"Need\",\n toShop: \"To the shop\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Крафт\",\n byRarity: \"По редкости\",\n byCollection: \"По коллекции\",\n tradeUp: \"Обмен\",\n items: \"шт.\",\n contract: \"Контракт\",\n yourItems: \"Ваши подходящие предметы\",\n auto: \"Заполнить автоматически\",\n clear: \"Очистить\",\n craft: \"Создать\",\n free: \"Бесплатно\",\n canYield: \"Может выпасть\",\n noRecipes: \"Рецептов пока нет. Добавьте их в LiveOps → Craft.\",\n noItems: \"Подходящих предметов нет — они есть в магазине и сундуках.\",\n crafted: \"Готово!\",\n need: \"Нужно\",\n toShop: \"В магазин\",\n});\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "index.ts",
|
|
56
|
+
"content": "// @idosgames/mod-craft — Craft screen for any game: trade-up recipes by rarity or collection, a contract to fill with your items, price options and the reveal of what was crafted.\n\nexport { craftModule } from \"./module\";\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "model.ts",
|
|
60
|
+
"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"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "module.ts",
|
|
64
|
+
"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"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "module.meta.json",
|
|
68
|
+
"content": "{\n \"id\": \"craft\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Craft screen for any game: trade-up recipes by rarity or collection, a contract of owned items, auto-fill, price options and the result popup.\",\n \"description\": \"A ready craft screen (Unity Alikhan/Craft) for ANY game, built on the @idosgames/react/ui kit and client.craft. The screen lists the title's recipes as cards, lowest rarity first: what goes in (N items of a rarity or a collection) and what can come out. A recipe opens as a popup — the contract: the fitting items the player owns (worn equipment does not count), tap to add, 'Auto' fills from the most plentiful, the price options, and 'Craft'. The server rolls the result (only items with a roll Weight > 0 are promised) and the reward popup shows it flying into the inventory. 'Not enough coins' offers to open the shop (ctx.features.open('store')). Registers the 'craft' feature; its badge counts recipes the player can run now. Ships title-seed.json: 3 Common → Rare for coins and 3 Rare → Legendary for gems.\",\n \"provides\": [\n \"craft screen from the title's recipes\",\n \"trade-up by rarity or collection\",\n \"contract popup with owned items\",\n \"auto-fill of the contract\",\n \"price options\",\n \"craft result with reward animation\",\n \"craft badge for runnable recipes\",\n \"demo recipes\"\n ],\n \"tags\": [\"craft\", \"crafting\", \"trade-up\", \"items\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "deals",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Offers",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"summary": "Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.",
|
|
12
|
+
"description": "Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"special offers screen",
|
|
15
|
+
"auto-popup of a new deal",
|
|
16
|
+
"single, chain and pick-one offers",
|
|
17
|
+
"offer milestones",
|
|
18
|
+
"deals badge",
|
|
19
|
+
"demo offers"
|
|
20
|
+
],
|
|
21
|
+
"tags": [
|
|
22
|
+
"offers",
|
|
23
|
+
"deals",
|
|
24
|
+
"starter-pack",
|
|
25
|
+
"monetization",
|
|
26
|
+
"popup",
|
|
27
|
+
"ui",
|
|
28
|
+
"screen",
|
|
29
|
+
"economy"
|
|
30
|
+
],
|
|
31
|
+
"author": {
|
|
32
|
+
"name": "iDos Games",
|
|
33
|
+
"url": "https://idosgames.com"
|
|
34
|
+
},
|
|
35
|
+
"version": "0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@idosgames/core": "0.19.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
40
|
+
"@idosgames/react": "0.3.0",
|
|
41
|
+
"react": "19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"contentHash": "cc3d5ab811dc1299d64a4bcd4361d097ce776fcfd20949ddac015271111beed0",
|
|
44
|
+
"files": [
|
|
45
|
+
{
|
|
46
|
+
"path": "DealsScreen.tsx",
|
|
47
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from \"react\";\nimport type {\n ActiveDealSlotInfo,\n DealNodeDefinition,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Timer,\n canAfford,\n errorText,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeReward,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Special offers (Unity Alikhan/DealOffer): a deal pops up by itself when a new one starts (the\n// impression is recorded), and the \"Offers\" screen lists every active one. A deal opens as a popup\n// shaped by its graph — one big offer, a chain of steps (each unlocks the next), or cards to choose\n// one from — with its milestone bar under it.\n\n/** Active deals, shared by the screen and the auto-popup (one request for both). */\nexport function createDealsStore(features: FeatureRegistry) {\n let slots: ActiveDealSlotInfo[] | null = null;\n const listeners = new Set<() => void>();\n const set = (next: ActiveDealSlotInfo[]) => {\n slots = [...next].sort(\n (a, b) => Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0),\n );\n features.setBadge(\"deals\", dealsBadge(slots));\n for (const l of listeners) l();\n };\n return {\n get: () => slots,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n const res = await client.dealOffer.getActiveDeals();\n set(res.ok ? (res.data.Slots ?? []) : []);\n },\n };\n}\nexport type DealsStore = ReturnType<typeof createDealsStore>;\n\nexport function makeDealsScreen(store: DealsStore) {\n return function DealsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const [open, setOpen] = useState<string | null>(null);\n const stagger = useStagger();\n const catalog = useCatalog();\n const now = useNow();\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!slots)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (slots.length === 0)\n return <EmptyState glyph=\"offer\" text={t(\"noDeals\")} />;\n const current = slots.find((s) => s.SlotID === open);\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {slots.map((s, i) => (\n <Card\n key={s.SlotID!}\n onClick={() => setOpen(s.SlotID!)}\n style={{\n display: \"grid\",\n gap: 8,\n background: \"linear-gradient(180deg, #7b4be0 0%, #3f2699 100%)\",\n ...stagger(i),\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={s.OfferDef?.Identity?.AssetPaths?.icon ?? \"offer\"}\n size={44}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(\n s.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n </div>\n <Timer\n at={s.ComputedExpiresAtUtc}\n now={now}\n label={t(\"endsIn\")}\n />\n </div>\n </div>\n <ResourceList lines={offerRewards(s)} size={22} />\n </Card>\n ))}\n {current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n };\n}\n\n/** The deal that starts pops up by itself, once per session per slot; the impression is recorded. */\nexport function makeDealsWatcher(store: DealsStore) {\n return function DealsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const slots = useSyncExternalStore(store.subscribe, store.get, store.get);\n const shown = useRef(new Set<string>());\n const [popup, setPopup] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => {\n if (popup || !slots) return;\n const next = slots.find(\n (s) => s.IsNewActivation && s.SlotID && !shown.current.has(s.SlotID),\n );\n if (!next?.SlotID) return;\n shown.current.add(next.SlotID);\n void client.dealOffer.recordShow(next.SlotID);\n setPopup(next.SlotID);\n }, [slots, popup, client]);\n const current = slots?.find((s) => s.SlotID === popup);\n return current ? (\n <DealPopup\n slot={current}\n store={store}\n onClose={() => setPopup(null)}\n queued\n />\n ) : null;\n };\n}\n\n/** `queued` — popped up by itself (the watcher): waits for other auto-popups. */\nfunction DealPopup({\n slot,\n store,\n onClose,\n queued,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n onClose: () => void;\n queued?: boolean;\n}): ReactNode {\n const catalog = useCatalog();\n const now = useNow();\n const shape = dealShape(slot);\n const nodes = orderedNodes(slot).filter(\n (n) =>\n !(n.HideWhenCompleted && nodeStatus(slot, n) === \"Completed\") &&\n nodeStatus(slot, n) !== \"Hidden\",\n );\n return (\n <Popup\n title={catalog.localize(\n slot.OfferDef?.Identity?.DisplayName ?? t(\"specialOffer\"),\n )}\n onClose={onClose}\n width={shape === \"choice\" ? 600 : 460}\n variant=\"center\"\n queued={queued}\n >\n {slot.OfferDef?.Identity?.Description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 14,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(slot.OfferDef.Identity.Description)}\n </div>\n ) : null}\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <Timer\n at={slot.ComputedExpiresAtUtc}\n now={now}\n label={`⏱ ${t(\"endsIn\")}`}\n />\n </div>\n {shape === \"choice\" ? (\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"choose\")}\n </div>\n ) : null}\n <div\n style={\n shape === \"choice\"\n ? {\n display: \"grid\",\n gridTemplateColumns: `repeat(${Math.min(3, nodes.length)}, minmax(0, 1fr))`,\n gap: 10,\n }\n : { display: \"grid\", gap: 10 }\n }\n >\n {nodes.map((node, i) => (\n <NodeCard\n key={node.NodeID ?? i}\n slot={slot}\n node={node}\n index={i}\n shape={shape}\n store={store}\n />\n ))}\n </div>\n <Milestones slot={slot} store={store} />\n </Popup>\n );\n}\n\nfunction NodeCard({\n slot,\n node,\n index,\n shape,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n node: DealNodeDefinition;\n index: number;\n shape: string;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const stagger = useStagger();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const status = nodeStatus(slot, node);\n const kind = nodeKind(node);\n const price = nodePrice(node);\n const reward = nodeReward(node);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(price, balances);\n const store_ = needsStore(node);\n const done = status === \"Completed\";\n const available = status === \"Available\" || status === \"InProgress\";\n\n const execute = async () => {\n setBusy(true);\n const res = await client.dealOffer.executeNode(slot.SlotID!, node.NodeID!);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"purchase\" ? \"purchase\" : \"claim\");\n kit.fx.burst(ref.current);\n const granted = grantedBy(res.data);\n const lines = granted.length > 0 ? granted : reward;\n if (res.data.OfferExhausted) {\n // The finished offer leaves the active list — its bar's reward is taken now or never.\n for (const id of milestonesReachedBy(slot, node)) {\n const bonus = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (bonus.ok) lines.push(...grantedBy(bonus.data));\n }\n close();\n }\n celebrate(lines);\n await store.load(client);\n };\n\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 12,\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n opacity: status === \"Locked\" ? 0.55 : 1,\n border: available ? `2px solid ${v.gold}` : panel.border,\n ...stagger(index),\n }}\n >\n {shape === \"chain\" ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"step\")} {index + 1}\n </div>\n ) : null}\n {node.Identity?.DisplayName ? (\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(node.Identity.DisplayName)}\n </div>\n ) : null}\n <ResourceList\n lines={reward}\n size={shape === \"single\" ? 36 : 26}\n gap={12}\n />\n {done ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={22} /> {t(\"done\")}\n </span>\n ) : status === \"Locked\" ? (\n <span\n style={{\n display: \"inline-flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n }}\n >\n <Icon glyph=\"lock\" size={20} /> {t(\"locked\")}\n </span>\n ) : kind === \"video\" ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"adsUnavailable\")}\n </Button>\n ) : kind === \"info\" ? (\n <Button\n tone=\"blue\"\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n >\n {t(\"gotIt\")}\n </Button>\n ) : store_ ? (\n <Button tone=\"grey\" disabled style={{ width: \"100%\" }}>\n {t(\"storeOnly\")}\n </Button>\n ) : (\n <Button\n tone={kind === \"free\" ? \"green\" : \"gold\"}\n attract={affordable}\n disabled={!affordable}\n busy={busy}\n onClick={() => void execute()}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`deal:${node.NodeID}`}\n >\n {kind === \"free\" ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={18} dimIf={() => !affordable} />\n )}\n </Button>\n )}\n </div>\n );\n}\n\nfunction Milestones({\n slot,\n store,\n}: {\n slot: ActiveDealSlotInfo;\n store: DealsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const rows = dealMilestones(slot);\n if (rows.length === 0) return null;\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const max = rows[rows.length - 1]?.required ?? 1;\n const claim = async (id: string) => {\n const res = await client.dealOffer.claimMilestone(slot.SlotID!, id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await store.load(client);\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"milestones\")}\n </div>\n <ProgressBar value={Math.min(earned, max)} max={max} tone=\"gold\" />\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rows.map((m) => (\n <Button\n key={m.id}\n size=\"sm\"\n tone={m.state === \"ready\" ? \"gold\" : \"grey\"}\n attract={m.state === \"ready\"}\n disabled={m.state !== \"ready\"}\n onClick={() => void claim(m.id)}\n >\n {m.state === \"claimed\" ? <Icon glyph=\"check\" size={16} /> : null}{\" \"}\n {m.required} <ResourceList lines={m.reward} size={16} />\n </Button>\n ))}\n </div>\n </div>\n );\n}\n"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "i18n.ts",
|
|
51
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Offers\",\n specialOffer: \"Special offer\",\n endsIn: \"Ends in\",\n take: \"Take\",\n free: \"Free\",\n claim: \"Claim\",\n done: \"Done\",\n locked: \"Locked\",\n step: \"Step\",\n choose: \"Choose one\",\n watchAd: \"Watch a video\",\n adsUnavailable: \"Videos are not available here\",\n storeOnly: \"Available in the app store\",\n milestones: \"Bonus bar\",\n later: \"Later\",\n noDeals: \"No offers right now — check back later.\",\n gotIt: \"OK\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Акции\",\n specialOffer: \"Специальное предложение\",\n endsIn: \"До конца\",\n take: \"Взять\",\n free: \"Бесплатно\",\n claim: \"Забрать\",\n done: \"Получено\",\n locked: \"Закрыто\",\n step: \"Шаг\",\n choose: \"Выберите одно\",\n watchAd: \"Смотреть видео\",\n adsUnavailable: \"Видео здесь недоступно\",\n storeOnly: \"Доступно в магазине приложений\",\n milestones: \"Бонусная шкала\",\n later: \"Позже\",\n noDeals: \"Сейчас предложений нет — загляните позже.\",\n gotIt: \"Понятно\",\n});\n"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "index.ts",
|
|
55
|
+
"content": "// @idosgames/mod-deals — Special offers for any game: timed deals that pop up by themselves, single offers, step chains and choice cards, with a milestone bar.\n\nexport { dealsModule } from \"./module\";\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "model.ts",
|
|
59
|
+
"content": "// Pure helpers of the deal offers (Unity Alikhan/DealOffer): what shape an offer's node graph has,\n// in which order to draw its steps, each step's state, price and reward, and the milestone bar.\n// The server is the authority on node status — `ActivationState` is read, never recomputed.\n// Covered by model.test.ts.\n\nimport type { ActiveDealSlotInfo, DealNodeDefinition } from \"@idosgames/core\";\nimport { costLines, grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport type DealShape = \"single\" | \"chain\" | \"choice\" | \"graph\";\n\nexport function nodesOf(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n return slot.OfferDef?.Graph?.Nodes ?? [];\n}\n\n/**\n * The shape is derived from the graph (there is no declared mode): one node → single; the roots\n * share a ChoiceGroupID → choice; every node leads to at most one next → chain; else a graph.\n */\nexport function dealShape(slot: ActiveDealSlotInfo): DealShape {\n const nodes = nodesOf(slot);\n if (nodes.length <= 1) return \"single\";\n const roots = slot.OfferDef?.Graph?.RootNodeIDs ?? [];\n const rootNodes = nodes.filter((n) => roots.includes(n.NodeID ?? \"\"));\n const group = rootNodes[0]?.ChoiceGroupID;\n if (\n rootNodes.length > 1 &&\n group &&\n rootNodes.every((n) => n.ChoiceGroupID === group)\n )\n return \"choice\";\n if (\n roots.length === 1 &&\n nodes.every((n) => (n.NextNodeIDs?.length ?? 0) <= 1)\n )\n return \"chain\";\n return \"graph\";\n}\n\n/** Steps in play order: from the roots along NextNodeIDs (breadth-first), then anything left. */\nexport function orderedNodes(slot: ActiveDealSlotInfo): DealNodeDefinition[] {\n const nodes = nodesOf(slot);\n const byID = new Map(nodes.map((n) => [n.NodeID ?? \"\", n]));\n const out: DealNodeDefinition[] = [];\n const seen = new Set<string>();\n const queue = [...(slot.OfferDef?.Graph?.RootNodeIDs ?? [])];\n while (queue.length > 0) {\n const id = queue.shift()!;\n if (seen.has(id) || !byID.has(id)) continue;\n seen.add(id);\n const node = byID.get(id)!;\n out.push(node);\n queue.push(...(node.NextNodeIDs ?? []));\n }\n for (const n of nodes) if (!seen.has(n.NodeID ?? \"\")) out.push(n);\n return out;\n}\n\nexport type NodeStatus =\n \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\";\n\n/** The server's status of a step; a root with no state yet is available, anything else locked. */\nexport function nodeStatus(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): NodeStatus {\n const id = node.NodeID ?? \"\";\n const s = slot.ActivationState?.Nodes?.[id]?.Status as NodeStatus | undefined;\n if (s) return s;\n return (slot.OfferDef?.Graph?.RootNodeIDs ?? []).includes(id)\n ? \"Available\"\n : \"Locked\";\n}\n\nexport function nodeKind(\n node: DealNodeDefinition,\n): \"purchase\" | \"free\" | \"video\" | \"info\" {\n const type = String(\n node.Type ??\n (node.Action as { Type?: unknown } | null | undefined)?.Type ??\n \"\",\n ).toLowerCase();\n if (type.includes(\"video\")) return \"video\";\n if (type.includes(\"info\")) return \"info\";\n if (type.includes(\"free\")) return \"free\";\n return nodePrice(node).length === 0 ? \"free\" : \"purchase\";\n}\n\n/** The first way to pay (currencies / items). An entry paid in a store is not a resource cost. */\nexport function nodePrice(node: DealNodeDefinition): ResourceLine[] {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n return first\n ? costLines(first.Cost as never).filter((l) => l.kind !== \"other\")\n : [];\n}\n\n/** Whether the first way to pay needs a store receipt (real money). */\nexport function needsStore(node: DealNodeDefinition): boolean {\n const first = Object.values(node.Pricing?.Options ?? {}).find((o) => o);\n const entries =\n (\n first?.Cost as\n | {\n Standard?: {\n Entries?: Array<{ Type?: string | null } | null> | null;\n } | null;\n }\n | null\n | undefined\n )?.Standard?.Entries ?? [];\n return entries.some((e) => e?.Type === \"Purchase\");\n}\n\nexport function nodeReward(node: DealNodeDefinition): ResourceLine[] {\n return grantLines(node.Reward?.Grants);\n}\n\n/** Everything the offer can give across its steps (the card preview). */\nexport function offerRewards(slot: ActiveDealSlotInfo): ResourceLine[] {\n const merged = new Map<string, ResourceLine>();\n for (const n of nodesOf(slot))\n for (const l of nodeReward(n)) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n return [...merged.values()];\n}\n\nexport interface DealMilestoneRow {\n id: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function dealMilestones(slot: ActiveDealSlotInfo): DealMilestoneRow[] {\n const earned = Number(slot.Milestone?.TotalEarned ?? 0);\n const claimed = slot.Milestone?.ClaimedIDs ?? [];\n const ready = slot.Milestone?.ReachedUnclaimedIDs ?? [];\n return Object.entries(slot.OfferDef?.Milestones?.Milestones ?? {})\n .map(([id, m]) => ({\n id: m?.MilestoneID ?? id,\n required: Number(m?.RequiredProgress ?? 0),\n reward: grantLines(m?.Rewards),\n }))\n .sort((a, b) => a.required - b.required)\n .map((m) => ({\n ...m,\n state: claimed.includes(m.id)\n ? \"claimed\"\n : ready.includes(m.id) || earned >= m.required\n ? \"ready\"\n : \"locked\",\n }));\n}\n\n/**\n * Milestones the step brings within reach (its MilestonePoints on top of what the bar holds) that\n * are not claimed yet. The step that exhausts the offer takes the slot out of GetActiveDeals, so\n * the screen claims these right away — otherwise the bar's last reward has nowhere to be taken.\n */\nexport function milestonesReachedBy(\n slot: ActiveDealSlotInfo,\n node: DealNodeDefinition,\n): string[] {\n const earned =\n Number(slot.Milestone?.TotalEarned ?? 0) +\n Number(node.Reward?.MilestonePoints ?? 0);\n return dealMilestones(slot)\n .filter((m) => m.state !== \"claimed\" && m.required <= earned)\n .map((m) => m.id);\n}\n\n/** Badge: steps the player can take for free right now (a pick-one group counts once) plus\n * milestone rewards waiting. */\nexport function dealsBadge(slots: ActiveDealSlotInfo[]): number {\n let n = 0;\n for (const s of slots) {\n const groups = new Set<string>();\n for (const node of nodesOf(s)) {\n if (nodeStatus(s, node) !== \"Available\" || nodeKind(node) !== \"free\")\n continue;\n if (node.ChoiceGroupID) groups.add(node.ChoiceGroupID);\n else n++;\n }\n n += groups.size + (s.Milestone?.ReachedUnclaimedIDs?.length ?? 0);\n }\n return n;\n}\n"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "module.ts",
|
|
63
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createDealsStore,\n makeDealsScreen,\n makeDealsWatcher,\n} from \"./DealsScreen\";\nimport { t } from \"./i18n\";\n\n// Special offers as a feature of the game. The always-on watcher pops a newly started deal up by\n// itself (and records the impression); the screen lists every active deal. Badge: free steps and\n// milestone rewards waiting.\nexport const dealsModule: Module = defineModule({\n id: \"deals\",\n meta: { name: \"Offers\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createDealsStore(ctx.features);\n ctx.features.register({\n id: \"deals\",\n label: t(\"title\"),\n icon: \"offer\",\n group: \"economy\",\n order: 15,\n available: false,\n Screen: makeDealsScreen(store),\n });\n // The tab shows while a deal is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"deals\",\n (store.get()?.length ?? 0) > 0,\n \"no active deal right now (DealOffer slots)\",\n ),\n );\n ctx.registerPanel({\n id: \"auto\",\n slot: \"modal\",\n activeOnly: false,\n component: makeDealsWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"deals\",\n active: store.get()?.map((s) => s.SlotID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "module.meta.json",
|
|
67
|
+
"content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.\",\n \"description\": \"Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.\",\n \"provides\": [\n \"special offers screen\",\n \"auto-popup of a new deal\",\n \"single, chain and pick-one offers\",\n \"offer milestones\",\n \"deals badge\",\n \"demo offers\"\n ],\n \"tags\": [\n \"offers\",\n \"deals\",\n \"starter-pack\",\n \"monetization\",\n \"popup\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "events",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Events",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"summary": "Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.",
|
|
12
|
+
"description": "A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) and the VIP pass (its benefits and prices in currencies) are popups. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"events screen with a tab per active event",
|
|
15
|
+
"event timer and token progress bar",
|
|
16
|
+
"milestone track with rewards",
|
|
17
|
+
"VIP second prize and top-up after purchase",
|
|
18
|
+
"claim all milestones",
|
|
19
|
+
"how-to-earn popup with daily caps",
|
|
20
|
+
"VIP pass purchase popup",
|
|
21
|
+
"events badge",
|
|
22
|
+
"demo weekly event"
|
|
23
|
+
],
|
|
24
|
+
"tags": [
|
|
25
|
+
"events",
|
|
26
|
+
"live-ops",
|
|
27
|
+
"limited-time",
|
|
28
|
+
"milestones",
|
|
29
|
+
"battle-pass",
|
|
30
|
+
"vip",
|
|
31
|
+
"ui",
|
|
32
|
+
"screen"
|
|
33
|
+
],
|
|
34
|
+
"author": {
|
|
35
|
+
"name": "iDos Games",
|
|
36
|
+
"url": "https://idosgames.com"
|
|
37
|
+
},
|
|
38
|
+
"version": "0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@idosgames/core": "0.19.0",
|
|
42
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
43
|
+
"@idosgames/react": "0.3.0",
|
|
44
|
+
"react": "19.2.7"
|
|
45
|
+
},
|
|
46
|
+
"contentHash": "4f8a725287979ac38c7332be0b9085920b08ca774914bf7c00bce6dcaa0cc9dc",
|
|
47
|
+
"files": [
|
|
48
|
+
{
|
|
49
|
+
"path": "EventsScreen.tsx",
|
|
50
|
+
"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 Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n 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 { VipPopup } from \"./VipPopup\";\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 and the VIP pass are popups.\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [vip, setVip] = useState<string | null>(null);\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 background: \"linear-gradient(180deg, #e0672b 0%, #8a2c1a 100%)\",\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: \"rgba(255,255,255,.85)\",\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 ? (\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={() => setVip(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 {vip ? <VipPopup premiumID={vip} onClose={() => setVip(null)} /> : 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"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "i18n.ts",
|
|
54
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Events\",\n noEvents: \"No events right now — check back soon.\",\n endsIn: \"Ends in\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n vip: \"VIP\",\n vipPrize: \"VIP prize\",\n vipTopUp: \"Claim VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipUntil: \"until\",\n vipBenefit:\n \"A second prize on every milestone — also for the ones you already claimed.\",\n buy: \"Buy\",\n howToEarn: \"How to earn\",\n playRound: \"Play a round\",\n perDay: \"per day\",\n shopping: \"Buy in the shop\",\n quests: \"Finish a quest\",\n login: \"Log in\",\n other: \"Other activity\",\n nextPrize: \"Next prize at\",\n allDone: \"All prizes collected!\",\n days: \"days\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Ивенты\",\n noEvents: \"Сейчас ивентов нет — загляните позже.\",\n endsIn: \"До конца\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n vip: \"VIP\",\n vipPrize: \"Приз VIP\",\n vipTopUp: \"Забрать VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipUntil: \"до\",\n vipBenefit: \"Второй приз на каждой вехе — и на тех, что вы уже забрали.\",\n buy: \"Купить\",\n howToEarn: \"Как получить\",\n playRound: \"Сыграйте раунд\",\n perDay: \"в день\",\n shopping: \"Покупка в магазине\",\n quests: \"Выполненное задание\",\n login: \"Вход в игру\",\n other: \"Другие действия\",\n nextPrize: \"Следующий приз на\",\n allDone: \"Все призы собраны!\",\n days: \"дн.\",\n});\n"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"path": "index.ts",
|
|
58
|
+
"content": "// @idosgames/mod-events — Limited-time events for any game: event tabs, a milestone track with rewards, claim all and a VIP second prize.\n\nexport { eventsModule } from \"./module\";\n"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "model.ts",
|
|
62
|
+
"content": "// Pure helpers of the events screen (Unity Core/Window LimitedTimeEvent): the milestone track of an\n// active event, its VIP second prize, and what the player can claim. The server resolves which events\n// are active and is the authority on every claim. Covered by model.test.ts.\n\nimport type { ActiveEventInfo, MilestoneDefinition } from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The player's premium passes, as the client caches them (`state.Premium`). */\nexport interface PremiumView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null\n > | null;\n MaxActiveTier?: number | null;\n}\n\ntype PremiumTier = {\n RequiredPremiumID?: string | null;\n MinPremiumTier?: number | null;\n Resources?: unknown;\n};\n\n/** Does the player hold the pass a premium bundle asks for (a named pass, or any pass of a tier)? */\nexport function hasPremium(\n premium: PremiumView | null | undefined,\n premiumID: string | null | undefined,\n minTier: number | null | undefined,\n nowMs: number,\n): boolean {\n if (premiumID) {\n const until = premium?.Subscriptions?.[premiumID]?.ExpirationDate;\n return !!until && new Date(until).getTime() > nowMs;\n }\n return (\n Number(premium?.MaxActiveTier ?? 0) >= Math.max(1, Number(minTier ?? 1))\n );\n}\n\n/** The server's key of a granted premium bundle: `{RequiredPremiumID|*}#{MinPremiumTier}`. */\nexport function premiumKey(tier: PremiumTier): string {\n return `${tier.RequiredPremiumID || \"*\"}#${Number(tier.MinPremiumTier ?? 0)}`;\n}\n\n/** Premium passes the rewards ask for (so the screen knows which pass to offer). */\nexport function premiumIDsOf(\n grants: Array<{ PremiumTiers?: PremiumTier[] | null } | null | undefined>,\n): string[] {\n const ids = new Set<string>();\n for (const g of grants)\n for (const t of g?.PremiumTiers ?? [])\n if (t?.RequiredPremiumID) ids.add(t.RequiredPremiumID);\n return [...ids];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string | null;\n required: number;\n featured: boolean;\n reward: ResourceLine[];\n /** The VIP second prize (empty when the milestone has none). */\n vip: ResourceLine[];\n /** Pass the VIP prize asks for (the first one). */\n vipPremiumID: string | null;\n state: \"claimed\" | \"ready\" | \"locked\";\n /**\n * none — no VIP prize; locked — the player has no pass; ready — the pass is held and the prize\n * can be taken (with the base, or topped up after it); granted — already taken.\n */\n vipState: \"none\" | \"locked\" | \"ready\" | \"granted\";\n}\n\nexport function eventEarned(e: ActiveEventInfo): number {\n return Number(e.Progress?.Balance?.TotalEarned ?? 0);\n}\n\n/** The event's milestones in threshold order, with what the player can do about each. */\nexport function milestoneRows(\n e: ActiveEventInfo,\n premium: PremiumView | null | undefined,\n nowMs: number,\n): MilestoneRow[] {\n const earned = eventEarned(e);\n const claims = e.Progress?.Milestone?.Claims ?? {};\n return Object.entries(e.Content?.Milestones ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const def = m as MilestoneDefinition;\n const id = def.MilestoneID || key;\n const claim = claims[id];\n const claimed = !!claim?.ClaimedAt;\n const reached = earned >= Number(def.RequiredProgress ?? 0);\n const tiers = (def.Rewards?.PremiumTiers ?? []) as PremiumTier[];\n const vip = tiers.flatMap((t) => bundleLines(t.Resources as never));\n const held = tiers.filter((t) =>\n hasPremium(premium, t.RequiredPremiumID, t.MinPremiumTier, nowMs),\n );\n const granted = claim?.PremiumTiers ?? [];\n const missing = held.filter((t) => !granted.includes(premiumKey(t)));\n let vipState: MilestoneRow[\"vipState\"] = \"locked\";\n if (tiers.length === 0) vipState = \"none\";\n else if (tiers.every((t) => granted.includes(premiumKey(t))))\n vipState = \"granted\";\n else if (missing.length > 0 && reached) vipState = \"ready\";\n return {\n id,\n name: def.DisplayName ?? null,\n required: Number(def.RequiredProgress ?? 0),\n featured: def.IsFeatured === true,\n reward: grantLines(def.Rewards),\n vip,\n vipPremiumID:\n tiers.find((t) => t.RequiredPremiumID)?.RequiredPremiumID ?? null,\n state: claimed ? \"claimed\" : reached ? \"ready\" : \"locked\",\n vipState,\n } satisfies MilestoneRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** What a tap on the row claims now: the base (with any held VIP part), or only the VIP top-up. */\nexport function claimableNow(row: MilestoneRow): boolean {\n return (\n row.state === \"ready\" ||\n (row.state === \"claimed\" && row.vipState === \"ready\")\n );\n}\n\n/** Progress toward the next unclaimed-or-unreached milestone, for the header bar. */\nexport function nextGoal(\n e: ActiveEventInfo,\n rows: MilestoneRow[],\n): { earned: number; goal: number; from: number } {\n const earned = eventEarned(e);\n const next = rows.find((r) => r.required > earned);\n const prev = [...rows].reverse().find((r) => r.required <= earned);\n return {\n earned,\n goal: next?.required ?? rows[rows.length - 1]?.required ?? earned,\n from: prev?.required ?? 0,\n };\n}\n\n/** Badge: milestones (or VIP top-ups) waiting across the active events. */\nexport function eventsBadge(\n events: ActiveEventInfo[],\n premium: PremiumView | null | undefined,\n nowMs: number,\n): number {\n return events.reduce(\n (n, e) => n + milestoneRows(e, premium, nowMs).filter(claimableNow).length,\n 0,\n );\n}\n\nexport interface TokenSourceView {\n kind: string;\n action: string | null;\n amount: number;\n dailyCap: number;\n}\n\n/** How the event's tokens are earned, for the \"how to get\" popup. */\nexport function tokenSources(e: ActiveEventInfo): TokenSourceView[] {\n return (e.Content?.TokenSources ?? []).map((s) => {\n const src = s as {\n SourceType?: string | null;\n BaseWeight?: number | null;\n Params?: Record<string, string> | null;\n Limits?: { DailyCap?: number | null } | null;\n };\n return {\n kind: String(src.SourceType ?? \"\"),\n action: src.Params?.ActionName ?? null,\n amount: Number(src.BaseWeight ?? 1),\n dailyCap: Number(src.Limits?.DailyCap ?? 0),\n };\n });\n}\n\n/**\n * Everything a claim paid, for the reward popup: the standard part plus every premium (VIP) bundle\n * of the applied operation, summed by resource. The generic reader takes only the standard part,\n * so a VIP prize paid with the base would not show. `null` = the response has no operation.\n */\nexport function claimedLines(data: unknown): ResourceLine[] | null {\n const d = data as\n { Resources?: OpView | null; Rewards?: OpView | null } | null | undefined;\n const op = d?.Resources ?? d?.Rewards;\n if (!op?.Grant) return null;\n const merged = new Map<string, ResourceLine>();\n const add = (lines: ResourceLine[]) => {\n for (const l of lines) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n };\n add(bundleLines(op.Grant.Standard as never));\n for (const t of op.Grant.PremiumTiers ?? [])\n add(bundleLines(t?.Resources as never));\n return [...merged.values()];\n}\ntype OpView = {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n};\n"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "module.ts",
|
|
66
|
+
"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"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"path": "VipPopup.tsx",
|
|
70
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n Icon,\n Popup,\n ResourceList,\n Spinner,\n canAfford,\n costLines,\n errorText,\n outlined,\n panel,\n usePopupClose,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The VIP pass (Unity PremiumPopUp): what it gives and a price option to buy it with currencies.\n// A pass paid in a store (real money) is not sold here — that is the platform's checkout.\n\n/** The part of a premium definition (config section Premium) the popup reads. */\ntype PremiumDefinition = {\n PremiumID?: string | null;\n DisplayName?: string | null;\n DurationDays?: number | null;\n Benefits?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Cost?: unknown } | null\n > | null;\n};\n\nexport function VipPopup({\n premiumID,\n onClose,\n}: {\n premiumID: string;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const [def, setDef] = useState<PremiumDefinition | null | undefined>(\n undefined,\n );\n useEffect(() => {\n void client.premium\n .getDefinitions()\n .then((res) =>\n setDef(\n res.ok\n ? ((res.data.Premium?.Definitions?.[premiumID] as\n PremiumDefinition | undefined) ?? null)\n : null,\n ),\n );\n }, [client, premiumID]);\n return (\n <Popup\n title={def?.DisplayName ?? t(\"vip\")}\n onClose={onClose}\n width={420}\n variant=\"center\"\n >\n {def === undefined ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : def === null ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n —\n </div>\n ) : (\n <VipOffer def={def} />\n )}\n </Popup>\n );\n}\n\nfunction VipOffer({ def }: { def: PremiumDefinition }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const toast = useToast();\n const kit = useUiKit();\n const close = usePopupClose();\n const [busy, setBusy] = useState<string | null>(null);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const options = Object.entries(def.PriceOptions ?? {})\n .filter(([, o]) => o)\n .map(([id, o]) => ({\n id: o?.OptionID || id,\n price: costLines(o?.Cost as never).filter((l) => l.kind !== \"other\"),\n }))\n .filter((o) => o.price.length > 0);\n\n const buy = async (optionID: string) => {\n setBusy(optionID);\n const res = await client.premium.purchaseItemOrCurrency(\n def.PremiumID!,\n crypto.randomUUID(),\n optionID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"purchase\");\n toast(t(\"vipActive\"), \"success\");\n close();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12, justifyItems: \"center\" }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"crown\" size={64} />\n </div>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"vipBenefit\")}\n </div>\n {Object.values(def.Benefits ?? {}).length > 0 ? (\n <div style={{ display: \"grid\", gap: 4, width: \"100%\" }}>\n {Object.values(def.Benefits ?? {}).map((b, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n gap: 8,\n alignItems: \"center\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {b}\n </div>\n ))}\n </div>\n ) : null}\n {Number(def.DurationDays ?? 0) > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {def.DurationDays} {t(\"days\")}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8, width: \"100%\" }}>\n {options.map((o) => {\n const affordable = canAfford(o.price, balances);\n return (\n <Button\n key={o.id}\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === o.id}\n onClick={() => void buy(o.id)}\n data-tutorial-anchor=\"vip:buy\"\n >\n {t(\"buy\")}{\" \"}\n <ResourceList\n lines={o.price}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n );\n })}\n </div>\n </div>\n );\n}\n"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"path": "module.meta.json",
|
|
74
|
+
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) and the VIP pass (its benefits and prices in currencies) are popups. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"VIP pass purchase popup\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "friends",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Friends",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"summary": "Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.",
|
|
12
|
+
"description": "A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"friends list",
|
|
15
|
+
"incoming and sent friend requests",
|
|
16
|
+
"friend suggestions",
|
|
17
|
+
"notification inbox",
|
|
18
|
+
"add a friend by ID",
|
|
19
|
+
"player card with message, remove and block",
|
|
20
|
+
"friends badge"
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
"friends",
|
|
24
|
+
"social",
|
|
25
|
+
"requests",
|
|
26
|
+
"inbox",
|
|
27
|
+
"notifications",
|
|
28
|
+
"ui",
|
|
29
|
+
"screen"
|
|
30
|
+
],
|
|
31
|
+
"author": {
|
|
32
|
+
"name": "iDos Games",
|
|
33
|
+
"url": "https://idosgames.com"
|
|
34
|
+
},
|
|
35
|
+
"version": "0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@idosgames/core": "0.19.0",
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
40
|
+
"@idosgames/react": "0.3.0",
|
|
41
|
+
"react": "19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"contentHash": "a7ff6c415397f9e9f406bffa2b8f26da60ab46a1ebdb0d7b0155c3fe843dc862",
|
|
44
|
+
"files": [
|
|
45
|
+
{
|
|
46
|
+
"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 const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\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 rgba(255,255,255,.6)\",\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 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 {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"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "i18n.ts",
|
|
51
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Friends\",\n friends: \"Friends\",\n requests: \"Requests\",\n suggested: \"Suggested\",\n inbox: \"News\",\n noFriends: \"No friends yet — add someone by ID or pick from the suggestions.\",\n noRequests: \"No requests right now.\",\n noSuggestions: \"No suggestions right now — come back later.\",\n noInbox: \"Nothing new yet.\",\n incoming: \"Want to be friends\",\n outgoing: \"Waiting for an answer\",\n accept: \"Accept\",\n decline: \"Decline\",\n add: \"Add\",\n addFriend: \"Add a friend\",\n sent: \"Request sent\",\n playerID: \"Player ID\",\n yourID: \"Your ID\",\n copied: \"Copied\",\n send: \"Send request\",\n badID: \"Enter another player's ID\",\n message: \"Message\",\n remove: \"Remove friend\",\n removeAsk: \"Remove this player from your friends?\",\n block: \"Block\",\n blockAsk:\n \"Block this player? You stop seeing each other, and the friendship ends.\",\n blocked: \"Blocked\",\n platformFriend: \"Friend on idosgames.com\",\n level: \"Level\",\n power: \"Power\",\n someone: \"Someone\",\n and: \"and\",\n more: \"more\",\n vAttack: \"attacked you\",\n vRaid: \"raided you\",\n vFriend: \"is now your friend\",\n vSale: \"bought your creation\",\n vFollow: \"followed you\",\n vLike: \"liked your creation\",\n vComment: \"commented\",\n vReply: \"replied to you\",\n vOther: \"did something\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Друзья\",\n friends: \"Друзья\",\n requests: \"Заявки\",\n suggested: \"Рекомендуем\",\n inbox: \"Новости\",\n noFriends:\n \"Друзей пока нет — добавьте игрока по ID или выберите из рекомендаций.\",\n noRequests: \"Заявок нет.\",\n noSuggestions: \"Рекомендаций пока нет — загляните позже.\",\n noInbox: \"Пока ничего нового.\",\n incoming: \"Хотят дружить\",\n outgoing: \"Ждут ответа\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n add: \"Добавить\",\n addFriend: \"Добавить друга\",\n sent: \"Заявка отправлена\",\n playerID: \"ID игрока\",\n yourID: \"Ваш ID\",\n copied: \"Скопировано\",\n send: \"Отправить заявку\",\n badID: \"Введите ID другого игрока\",\n message: \"Написать\",\n remove: \"Удалить из друзей\",\n removeAsk: \"Удалить игрока из друзей?\",\n block: \"Заблокировать\",\n blockAsk:\n \"Заблокировать игрока? Вы перестанете видеть друг друга, дружба закончится.\",\n blocked: \"Заблокирован\",\n platformFriend: \"Друг на idosgames.com\",\n level: \"Уровень\",\n power: \"Сила\",\n someone: \"Кто-то\",\n and: \"и\",\n more: \"ещё\",\n vAttack: \"напал на вас\",\n vRaid: \"совершил набег\",\n vFriend: \"теперь ваш друг\",\n vSale: \"купил ваше творение\",\n vFollow: \"подписался на вас\",\n vLike: \"оценил ваше творение\",\n vComment: \"оставил комментарий\",\n vReply: \"ответил вам\",\n vOther: \"что-то сделал\",\n});\n"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "index.ts",
|
|
55
|
+
"content": "// @idosgames/mod-friends — Friends for any game: friends, requests, suggestions and the notification inbox, with a player card in a popup.\n\nexport { friendsModule } from \"./module\";\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "model.ts",
|
|
59
|
+
"content": "// Pure helpers of the friends screen (Unity Runtime/UI/Friends): names, sorting, what can be done\n// with a friend, the inbox lines and the badge. The server owns the graph — every rule (limits,\n// platform friends, blocks) is enforced there. Covered by model.test.ts.\n\nexport interface ProfileView {\n UserID: string;\n PublicData?: {\n Username?: string | null;\n AvatarUrl?: string | null;\n Level?: number | null;\n Power?: number | null;\n Country?: string | null;\n } | null;\n Source?: \"Title\" | \"Platform\" | null;\n}\n\n/** A player's name: the chosen username, else a short form of the id. */\nexport function displayName(\n p:\n | {\n UserID?: string | null;\n PublicData?: { Username?: string | null } | null;\n }\n | null\n | undefined,\n): string {\n const name = p?.PublicData?.Username?.trim();\n if (name) return name;\n const id = p?.UserID ?? \"\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id || \"?\";\n}\n\n/** The letter for an avatar without a picture. */\nexport function initial(p: ProfileView | null | undefined): string {\n return (displayName(p).trim()[0] ?? \"?\").toUpperCase();\n}\n\nexport function sortByName<T extends ProfileView>(list: readonly T[]): T[] {\n return [...list].sort((a, b) => displayName(a).localeCompare(displayName(b)));\n}\n\n/** A platform friend is a mutual follow on idosgames.com — it is removed there, not in the game. */\nexport function canRemove(friend: ProfileView): boolean {\n return friend.Source !== \"Platform\";\n}\n\n/** A player id typed by hand: trimmed, one token, not my own. */\nexport function cleanUserID(\n input: string,\n myID: string | null | undefined,\n): string | null {\n const id = input.trim();\n if (!id || /\\s/.test(id) || id === myID) return null;\n return id;\n}\n\n/** Badge: requests waiting for my answer + unread notifications. */\nexport function friendsBadge(\n social: { IncomingCount?: number | null } | null | undefined,\n inboxUnread: number,\n): number {\n return (\n Math.max(0, Number(social?.IncomingCount ?? 0)) + Math.max(0, inboxUnread)\n );\n}\n\nexport type InboxKind =\n | \"attack\"\n | \"raid\"\n | \"friend\"\n | \"sale\"\n | \"follow\"\n | \"like\"\n | \"comment\"\n | \"reply\"\n | \"other\";\n\n/** The server's verbs are open-ended strings; anything new falls into \"other\". */\nexport function inboxKind(verb: string | null | undefined): InboxKind {\n switch (verb) {\n case \"Attack\":\n return \"attack\";\n case \"Raid\":\n return \"raid\";\n case \"FriendAdded\":\n return \"friend\";\n case \"WorkshopSale\":\n return \"sale\";\n case \"Followed\":\n return \"follow\";\n case \"Liked\":\n return \"like\";\n case \"Commented\":\n return \"comment\";\n case \"Replied\":\n return \"reply\";\n default:\n return \"other\";\n }\n}\n\n/** \"Ann\", \"Ann and Bob\", \"Ann and 41 more\" — the actors of an aggregated notification. */\nexport function actorsText(\n actors: Array<{ DisplayName?: string | null } | null> | null | undefined,\n count: number | null | undefined,\n someone: string,\n and: string,\n more: string,\n): string {\n const names = (actors ?? []).map((a) => a?.DisplayName?.trim() || someone);\n const total = Math.max(Number(count ?? 0), names.length);\n if (names.length === 0) return someone;\n if (total <= 1) return names[0]!;\n if (total === 2 && names.length >= 2) return `${names[0]} ${and} ${names[1]}`;\n return `${names[0]} ${and} ${total - 1} ${more}`;\n}\n"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"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"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "module.meta.json",
|
|
67
|
+
"content": "{\n \"id\": \"friends\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.\",\n \"description\": \"A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.\",\n \"provides\": [\n \"friends list\",\n \"incoming and sent friend requests\",\n \"friend suggestions\",\n \"notification inbox\",\n \"add a friend by ID\",\n \"player card with message, remove and block\",\n \"friends badge\"\n ],\n \"tags\": [\n \"friends\",\n \"social\",\n \"requests\",\n \"inbox\",\n \"notifications\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|