@idosgames/mcp 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +14 -6
  4. package/registry/index.json +731 -33
  5. package/registry/modules/board-game.json +4 -4
  6. package/registry/modules/character-ui.json +78 -0
  7. package/registry/modules/chat-ui.json +74 -0
  8. package/registry/modules/craft-ui.json +74 -0
  9. package/registry/modules/deal-offer-ui.json +73 -0
  10. package/registry/modules/events-ui.json +80 -0
  11. package/registry/modules/friends-ui.json +73 -0
  12. package/registry/modules/game-hud.json +15 -10
  13. package/registry/modules/idle-rpg.json +4 -4
  14. package/registry/modules/inventory-ui.json +73 -0
  15. package/registry/modules/leaderboard-ui.json +75 -0
  16. package/registry/modules/marketplace-ui.json +75 -0
  17. package/registry/modules/profile-ui.json +74 -0
  18. package/registry/modules/pvp-ui.json +72 -0
  19. package/registry/modules/referral-ui.json +73 -0
  20. package/registry/modules/rewards-ui.json +75 -0
  21. package/registry/modules/season-ui.json +79 -0
  22. package/registry/modules/showcase.json +92 -0
  23. package/registry/modules/store-ui.json +75 -0
  24. package/registry/modules/tutorial-ui.json +71 -0
  25. package/registry/modules/voxelcraft.json +75 -30
  26. package/registry/modules/workshop.json +3 -3
  27. package/registry/skills/idosgames-module-contract.json +1 -1
  28. package/registry/skills/idosgames-ui-kit.json +6 -0
  29. package/registry/skills/leaderboard-system.json +1 -1
  30. package/registry/skills/multiplayer-realtime.json +3 -3
  31. package/registry/skills/referral-system.json +2 -2
  32. package/registry/skills/timed-event-system.json +1 -1
@@ -45,10 +45,10 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@idosgames/core": "0.18.0",
49
- "@idosgames/module-sdk": "0.4.1",
50
- "@idosgames/react": "0.2.11",
51
- "@idosgames/wallet": "0.5.0",
48
+ "@idosgames/core": "0.19.0",
49
+ "@idosgames/module-sdk": "0.5.0",
50
+ "@idosgames/react": "0.3.0",
51
+ "@idosgames/wallet": "0.5.1",
52
52
  "@tanstack/react-query": "5.101.2",
53
53
  "react": "19.2.7",
54
54
  "three": "0.185.1",
@@ -0,0 +1,78 @@
1
+ {
2
+ "id": "character-ui",
3
+ "meta": {
4
+ "name": "Heroes",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.",
11
+ "description": "A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots.",
12
+ "provides": [
13
+ "hero roster with unlock for currency",
14
+ "rank up with price by the rank ladder",
15
+ "stat upgrades with costs, caps and requirements",
16
+ "stat breakdown popup by source",
17
+ "equipment slots and equip / take off",
18
+ "equip best gear automatically",
19
+ "item level upgrades",
20
+ "character skins",
21
+ "demo heroes"
22
+ ],
23
+ "tags": [
24
+ "heroes",
25
+ "characters",
26
+ "rpg",
27
+ "stats",
28
+ "equipment",
29
+ "gear",
30
+ "skins",
31
+ "ui",
32
+ "screen",
33
+ "economy"
34
+ ],
35
+ "author": {
36
+ "name": "iDos Games",
37
+ "url": "https://idosgames.com"
38
+ },
39
+ "version": "0.1.0"
40
+ },
41
+ "dependencies": {
42
+ "@idosgames/core": "0.19.0",
43
+ "@idosgames/module-sdk": "0.5.0",
44
+ "@idosgames/react": "0.3.0",
45
+ "react": "19.2.7"
46
+ },
47
+ "contentHash": "25261212eb2e582d0b2cd403ac1fe6406f2b76a5681d7cda1b99708ae136e25f",
48
+ "files": [
49
+ {
50
+ "path": "CharacterScreen.tsx",
51
+ "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 Badge,\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n costLines,\n errorText,\n formatAmount,\n outlined,\n panel,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\nimport {\n SKIN_KEY,\n bestLoadout,\n breakdown,\n fitProblem,\n gearPieces,\n itemLevelCost,\n pieceBonuses,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statCost,\n statMaxLevel,\n unmetRequirements,\n type CharacterModelView,\n type CharacterSection,\n type GearPiece,\n type InstanceView,\n type ItemDefWithGear,\n type RosterEntry,\n type SlotView,\n type StatView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\n// (equip / take off / level up), a slot's fitting gear, and skins.\n\nexport function makeCharacterScreen(features: FeatureRegistry) {\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Heroes\n features={features}\n openInstance={\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\n }\n />\n );\n };\n}\n\ntype PopupState =\n | { kind: \"heroes\" }\n | { kind: \"stats\" }\n | { kind: \"skins\" }\n | { kind: \"slot\"; slot: SlotView }\n | { kind: \"piece\"; instanceID: string }\n | null;\n\nconst SELECTED_KEY = \"idos-heroes-selected\";\n\nfunction useData() {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n // Definitions arrive with the title config; a title whose config did not carry them fetches them\n // once — and the screen redraws when they land (the section cache emits no user-state event).\n const [, loaded] = useState(0);\n const section = configSection<CharacterSection>(client, \"Character\");\n useEffect(() => {\n if (!section)\n void client.character\n .getCharacterDefinitions()\n .then(() => loaded((n) => n + 1));\n void client.character.getUserCharacters();\n // Gear instances (levels, where they are worn) live outside the player document on the server\n // and come with the full inventory read, not with the login state.\n void client.user.getUserInventory();\n }, [client, section]);\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const inventory = state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, InstanceView | null>;\n VirtualCurrencies?: Record<string, { Amount?: number }>;\n }\n | undefined;\n const items = catalog.items as Map<string, ItemDefWithGear>;\n // Not memoized: the SDK cache updates UnstackableItems IN PLACE (an equip flips EquippedSlot on\n // the same object), so a memo keyed by the object would keep showing the old state.\n const pieces = gearPieces(inventory?.UnstackableItems, items);\n return {\n client,\n section,\n owned,\n inventory,\n items,\n pieces,\n balances: inventory?.VirtualCurrencies,\n };\n}\n\nfunction Heroes({\n features,\n openInstance,\n}: {\n features: FeatureRegistry;\n openInstance: string | null;\n}): ReactNode {\n const { client, section, owned, items, pieces, balances } = useData();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const kit = useUiKit();\n const cardRef = useRef<HTMLDivElement>(null);\n const [popup, setPopup] = useState<PopupState>(\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\n );\n const [busy, setBusy] = useState<string | null>(null);\n const [selectedID, setSelectedID] = useState<string | null>(() => {\n try {\n return localStorage.getItem(SELECTED_KEY);\n } catch {\n return null;\n }\n });\n\n const list = roster(section, owned);\n if (list.length === 0)\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\n const hero =\n list.find((h) => h.id === selectedID && h.state !== \"locked\") ??\n list.find((h) => h.state !== \"locked\") ??\n list[0]!;\n const select = (id: string) => {\n setSelectedID(id);\n try {\n localStorage.setItem(SELECTED_KEY, id);\n } catch {\n // storage blocked: the choice lives for this session\n }\n };\n\n const ladder = resolveLadder(section, hero.def);\n const nextRank = rankCost(ladder, hero.rank);\n const slots = resolveSlots(section, hero.def);\n const changes =\n hero.state === \"locked\"\n ? []\n : bestLoadout(\n hero.id,\n hero.rank,\n slots,\n pieces,\n hero.model?.Equipment,\n items,\n );\n const rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\n\n const rankUp = async () => {\n setBusy(\"rank\");\n const res = await client.character.upgradeCharacterLevel(hero.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n kit.fx.burst(cardRef.current);\n };\n\n const equipBest = async () => {\n setBusy(\"best\");\n const res = await client.character.equipItems(hero.id, changes);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(cardRef.current);\n };\n\n const card = (\n <div\n ref={cardRef}\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\n border: `2px solid ${rarity}`,\n }}\n >\n <div\n className=\"idos-bounce\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 28,\n display: \"grid\",\n placeItems: \"center\",\n background: \"rgba(0,0,0,.25)\",\n border: `3px solid ${rarity}`,\n }}\n >\n <Icon\n glyph={\n hero.def.Identity?.AssetPaths?.portrait ??\n hero.def.Identity?.AssetPaths?.icon ??\n \"hero\"\n }\n size={80}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\n {hero.def.Classification?.ClassID ? (\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={t(\"rank\")}\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\n />\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\n </div>\n {hero.state === \"locked\" ? null : nextRank === null ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"maxRank\")}\n </div>\n ) : (\n <Button\n tone=\"gold\"\n attract={canAfford(nextRank, balances)}\n disabled={!canAfford(nextRank, balances)}\n busy={busy === \"rank\"}\n onClick={() => void rankUp()}\n data-tutorial-anchor=\"heroes:rank-up\"\n >\n {t(\"rankUp\")}{\" \"}\n {nextRank.length ? (\n <ResourceList\n lines={nextRank}\n size={18}\n dimIf={() => !canAfford(nextRank, balances)}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n )}\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"heroes\" })}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\n </Button>\n <Badge\n count={\n list.filter(\n (h) =>\n h.state === \"locked\" &&\n h.unlockPrice.length > 0 &&\n canAfford(h.unlockPrice, balances),\n ).length\n }\n dot\n />\n </span>\n {hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"stats\" })}\n >\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\n </Button>\n ) : null}\n {skinCount > 0 && hero.state !== \"locked\" ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup({ kind: \"skins\" })}\n >\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\n </Button>\n ) : null}\n </div>\n </div>\n );\n\n const equipment = (\n <div style={{ display: \"grid\", gap: 14 }}>\n <SectionTitle\n right={\n changes.length > 0 ? (\n <Button\n tone=\"green\"\n size=\"sm\"\n attract\n busy={busy === \"best\"}\n onClick={() => void equipBest()}\n data-tutorial-anchor=\"heroes:equip-best\"\n >\n {t(\"equipBest\")}\n </Button>\n ) : null\n }\n >\n {t(\"equipment\")}\n </SectionTitle>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n {slots.map((slot) => (\n <SlotTile\n key={slot.id}\n slot={slot}\n hero={hero}\n pieces={pieces}\n onClick={() => setPopup({ kind: \"slot\", slot })}\n />\n ))}\n </div>\n <Bag\n pieces={pieces.filter((p) => !p.equippedBy)}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n />\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {card}\n {equipment}\n </div>\n ) : (\n <>\n {card}\n {equipment}\n </>\n )}\n {popup?.kind === \"heroes\" ? (\n <HeroesPopup\n list={list}\n current={hero.id}\n balances={balances}\n onSelect={select}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"stats\" ? (\n <StatsPopup\n hero={hero}\n section={section}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"skins\" ? (\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\n ) : null}\n {popup?.kind === \"slot\" ? (\n <SlotPopup\n slot={popup.slot}\n hero={hero}\n pieces={pieces}\n items={items}\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup?.kind === \"piece\" ? (\n <PiecePopup\n instanceID={popup.instanceID}\n hero={hero}\n slots={slots}\n pieces={pieces}\n items={items}\n balances={balances}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction SlotTile({\n slot,\n hero,\n pieces,\n onClick,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n onClick: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id];\n const piece = worn?.ItemInstanceID\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\n : undefined;\n const locked = hero.rank < slot.minRank;\n const { play } = useUiKit();\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n {worn?.ItemID ? (\n <ItemTile\n itemID={worn.ItemID}\n level={piece?.level}\n onClick={onClick}\n size={80}\n />\n ) : (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => {\n play(\"click\");\n onClick();\n }}\n style={{\n width: 80,\n height: 80,\n borderRadius: 16,\n border: `3px dashed ${v.panelEdge}`,\n background: \"rgba(0,0,0,.25)\",\n display: \"grid\",\n placeItems: \"center\",\n cursor: \"pointer\",\n opacity: locked ? 0.5 : 1,\n }}\n >\n <Icon glyph={locked ? \"lock\" : \"plus\"} size={30} />\n </button>\n )}\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {locked ? `${t(\"slotLocked\")} ${slot.minRank}` : slot.id}\n </span>\n </div>\n );\n}\n\nfunction Bag({\n pieces,\n onPick,\n onShop,\n}: {\n pieces: GearPiece[];\n onPick: (instanceID: string) => void;\n onShop?: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\n const classes = [\n ...new Set(\n pieces.map((p) => catalog.items.get(p.itemID)?.ItemClass || \"other\"),\n ),\n ];\n const [tab, setTab] = useState(\"all\");\n const shown =\n tab === \"all\"\n ? pieces\n : pieces.filter(\n (p) => (catalog.items.get(p.itemID)?.ItemClass || \"other\") === tab,\n );\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"bag\")}</SectionTitle>\n {pieces.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={t(\"noGear\")}\n action={\n onShop ? (\n <Button onClick={onShop}>{catalog.localize(\"Shop\")}</Button>\n ) : undefined\n }\n />\n ) : (\n <>\n <Tabs\n tabs={[\n { id: \"all\", label: \"★\" },\n ...classes.map((c) => ({ id: c, label: c })),\n ]}\n value={tab}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\n gap: 10,\n justifyItems: \"center\",\n }}\n >\n {shown.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <ItemTile\n itemID={p.itemID}\n level={p.level}\n onClick={() => onPick(p.instanceID)}\n />\n </div>\n ))}\n </div>\n </>\n )}\n </div>\n );\n}\n\n// ── Popups ───────────────────────────────────────────────────────────────────────────────────\n\nfunction HeroesPopup({\n list,\n current,\n balances,\n onSelect,\n onClose,\n}: {\n list: RosterEntry[];\n current: string;\n balances: Record<string, { Amount?: number }> | undefined;\n onSelect: (id: string) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n\n const unlock = async (e: RosterEntry) => {\n setBusy(e.id);\n const res = await client.character.unlockCharacter(e.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"levelUp\");\n onSelect(e.id);\n };\n\n return (\n <Popup title={t(\"heroes\")} onClose={onClose} width={560}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\n gap: 10,\n }}\n >\n {list.map((e, i) => {\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\n const affordable = canAfford(e.unlockPrice, balances);\n return (\n <Card\n key={e.id}\n highlight={e.id === current}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n opacity: e.state === \"locked\" ? 0.85 : 1,\n border: `2px solid ${e.id === current ? v.gold : color}`,\n ...stagger(i),\n }}\n >\n <div style={{ position: \"relative\" }}>\n <Icon\n glyph={e.def.Identity?.AssetPaths?.icon ?? \"hero\"}\n size={56}\n />\n {e.state === \"locked\" ? (\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\n <Icon glyph=\"lock\" size={22} />\n </span>\n ) : null}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {e.state === \"locked\"\n ? t(\"locked\")\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\n </div>\n {e.state === \"locked\" ? (\n e.unlockPrice.length > 0 ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract={affordable}\n disabled={!affordable}\n busy={busy === e.id}\n onClick={() => void unlock(e)}\n style={{ width: \"100%\" }}\n >\n {t(\"unlock\")}{\" \"}\n <ResourceList lines={e.unlockPrice} size={16} />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )\n ) : e.id === current ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"selected\")}\n </span>\n ) : (\n <SelectButton onSelect={() => onSelect(e.id)} />\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n size=\"sm\"\n onClick={() => {\n onSelect();\n close();\n }}\n style={{ width: \"100%\" }}\n >\n {t(\"select\")}\n </Button>\n );\n}\n\nfunction StatsPopup({\n hero,\n section,\n pieces,\n items,\n balances,\n onClose,\n}: {\n hero: RosterEntry;\n section: CharacterSection | undefined;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const play = useSound();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const [detail, setDetail] = useState<StatView | null>(null);\n const stats = resolveStats(section, hero.def);\n const levels = hero.model?.StatLevels ?? {};\n const worn = Object.values(hero.model?.Equipment ?? {})\n .filter((e) => e?.ItemInstanceID)\n .map((e) => {\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\n });\n\n const upgrade = async (stat: StatView) => {\n setBusy(stat.id);\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(\"levelUp\");\n };\n\n return (\n <Popup\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\n onClose={onClose}\n width={480}\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n {stats.map((stat, i) => {\n const level = Number(levels[stat.id] ?? 0);\n const cap = statMaxLevel(stat, hero.rank, hero.def);\n const capped = cap > 0 && level >= cap;\n const cost = statCost(stat, level + 1);\n const missing = unmetRequirements(stat, levels);\n const affordable = canAfford(cost, balances);\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\n return (\n <Card\n key={stat.id}\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\n >\n <button\n type=\"button\"\n onClick={() => setDetail(stat)}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n color: v.text,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(stat.name)} ⓘ\n </span>\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {formatAmount(Math.round(total * 10) / 10)}\n </span>\n </button>\n {cap > 0 ? (\n <ProgressBar\n value={level}\n max={cap}\n label={`${t(\"level\")} ${level} / ${cap}`}\n height={16}\n />\n ) : null}\n {missing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"needs\")}:{\" \"}\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\n </div>\n ) : capped ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"statCapped\")}\n </div>\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n disabled={!affordable}\n busy={busy === stat.id}\n onClick={() => void upgrade(stat)}\n >\n {t(\"upgrade\")} →{\" \"}\n {formatAmount(\n Math.round(\n breakdown(stat, level + 1, hero.rank, hero.def, worn)\n .total * 10,\n ) / 10,\n )}{\" \"}\n <ResourceList\n lines={cost}\n size={16}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </Card>\n );\n })}\n </div>\n {detail ? (\n <BreakdownPopup\n stat={detail}\n level={Number(levels[detail.id] ?? 0)}\n hero={hero}\n worn={worn}\n onClose={() => setDetail(null)}\n />\n ) : null}\n </Popup>\n );\n}\n\nfunction BreakdownPopup({\n stat,\n level,\n hero,\n worn,\n onClose,\n}: {\n stat: StatView;\n level: number;\n hero: RosterEntry;\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\n const rows: Array<[string, number, string]> = [\n [t(\"base\"), b.base, v.text],\n [t(\"fromLevel\"), b.fromLevel, v.green],\n [t(\"fromRank\"), b.fromRank, v.blue],\n [t(\"fromGear\"), b.fromGear, v.gold],\n ];\n const round = (n: number) => formatAmount(Math.round(n * 10) / 10);\n return (\n <Popup\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\n onClose={onClose}\n width={380}\n variant=\"center\"\n >\n {stat.description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {catalog.localize(stat.description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n {rows.map(([label, value, color]) => (\n <div\n key={label}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{label}</span>\n <span style={{ color }}>\n {value === 0 && label !== t(\"base\")\n ? \"—\"\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\n </span>\n </div>\n ))}\n {b.percent ? (\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 15,\n }}\n >\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\n </div>\n ) : null}\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 18,\n }}\n >\n <span>{t(\"total\")}</span>\n <span style={{ color: v.gold }}>{round(b.total)}</span>\n </div>\n </div>\n </Popup>\n );\n}\n\nfunction SlotPopup({\n slot,\n hero,\n pieces,\n items,\n onPick,\n onClose,\n}: {\n slot: SlotView;\n hero: RosterEntry;\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n onPick: (instanceID: string) => void;\n onClose: () => void;\n}): ReactNode {\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\n const fitting = pieces.filter(\n (p) =>\n p.instanceID !== worn &&\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\n );\n const stagger = useStagger();\n return (\n <Popup title={slot.id} onClose={onClose} width={440}>\n {worn ? (\n <PickRow\n instanceID={worn}\n pieces={pieces}\n label={t(\"worn\")}\n onPick={onPick}\n />\n ) : null}\n {fitting.length === 0 ? (\n <EmptyState\n glyph=\"sword\"\n text={\n hero.rank < slot.minRank\n ? `${t(\"slotLocked\")} ${slot.minRank}`\n : t(\"noGear\")\n }\n />\n ) : (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"fits\")}\n </div>\n {fitting.map((p, i) => (\n <div key={p.instanceID} style={stagger(i)}>\n <PickRow\n instanceID={p.instanceID}\n pieces={pieces}\n onPick={onPick}\n />\n </div>\n ))}\n </div>\n )}\n </Popup>\n );\n}\n\nfunction PickRow({\n instanceID,\n pieces,\n label,\n onPick,\n}: {\n instanceID: string;\n pieces: GearPiece[];\n label?: string;\n onPick: (id: string) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n return (\n <Card\n onClick={() => onPick(instanceID)}\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\n >\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(piece.itemID)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label ? `${label} · ` : \"\"}\n {t(\"level\")} {piece.level}\n </div>\n </div>\n </Card>\n );\n}\n\nfunction PiecePopup({\n instanceID,\n hero,\n slots,\n pieces,\n items,\n balances,\n onClose,\n}: {\n instanceID: string;\n hero: RosterEntry;\n slots: SlotView[];\n pieces: GearPiece[];\n items: Map<string, ItemDefWithGear>;\n balances: Record<string, { Amount?: number }> | undefined;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const iconRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState<string | null>(null);\n const piece = pieces.find((p) => p.instanceID === instanceID);\n if (!piece) return null;\n const def = items.get(piece.itemID);\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\n const bonuses = pieceBonuses(def, piece.level);\n const fitSlot = slots.find(\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\n );\n const onThisHero =\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\n const levelCost = itemLevelCost(def, piece.level);\n const affordable = levelCost\n ? canAfford(levelCost as ResourceLine[], balances)\n : false;\n\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\n setBusy(kind);\n const res =\n kind === \"equip\" && fitSlot\n ? await client.character.equipItems(hero.id, [\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\n ])\n : kind === \"unequip\" && onThisHero\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\n : await client.item.upgradeLevel(piece.instanceID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\n kit.fx.burst(iconRef.current);\n };\n\n return (\n <Popup title={catalog.itemName(piece.itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div\n ref={iconRef}\n className=\"idos-glow\"\n style={{\n width: 110,\n height: 110,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({\n kind: \"item\",\n id: piece.itemID,\n amount: 1,\n })}\n size={72}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 14 }}>\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\n </div>\n {Object.entries(bonuses).map(([stat, value]) => (\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\n +{formatAmount(Math.round(value * 10) / 10)} {stat}\n </div>\n ))}\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\n ([stat, value]) => (\n <div\n key={stat}\n style={{ ...outlined, fontSize: 15, color: v.gold }}\n >\n +{value}% {stat}\n </div>\n ),\n )}\n {piece.equippedBy && !onThisHero ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"wornBy\")} {piece.equippedBy.characterID}\n </div>\n ) : null}\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {onThisHero ? (\n <Button\n tone=\"grey\"\n busy={busy === \"unequip\"}\n onClick={() => void act(\"unequip\")}\n >\n {t(\"unequip\")}\n </Button>\n ) : fitSlot ? (\n <Button\n tone=\"blue\"\n busy={busy === \"equip\"}\n onClick={() => void act(\"equip\")}\n >\n {t(\"equip\")} → {fitSlot.id}\n </Button>\n ) : (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"cannot\")}\n </div>\n )}\n {levelCost === null ? (\n def?.Upgrade ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"maxLevel\")}\n </div>\n ) : null\n ) : (\n <Button\n tone=\"gold\"\n disabled={!affordable}\n busy={busy === \"level\"}\n onClick={() => void act(\"level\")}\n >\n {t(\"levelUp\")}{\" \"}\n <ResourceList\n lines={levelCost}\n size={18}\n dimIf={() => !affordable}\n />\n </Button>\n )}\n </div>\n </Popup>\n );\n}\n\nfunction SkinsPopup({\n hero,\n onClose,\n}: {\n hero: RosterEntry;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const kit = useUiKit();\n const stagger = useStagger();\n const [view, setView] = useState<{\n WornSkinID?: string | null;\n Skins?: Array<{\n SkinID: string;\n Owned?: boolean | null;\n IsWorn?: boolean | null;\n Purchasable?: boolean | null;\n MeetsRequirements?: boolean | null;\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\n }> | null;\n } | null>(null);\n const [busy, setBusy] = useState<string | null>(null);\n\n const load = async () => {\n const res = await client.character.getCharacterSkins(hero.id);\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\n };\n useEffect(() => {\n void load();\n // once per hero\n }, [hero.id]);\n\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\n setBusy(skinID);\n const res =\n kind === \"buy\"\n ? await client.character.unlockSkin(hero.id, skinID, {\n autoEquip: true,\n })\n : kind === \"base\"\n ? await client.character.unequipSkin(hero.id)\n : await client.character.equipSkin(hero.id, skinID);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\n await load();\n };\n\n const defs = hero.def.Skins?.Definitions ?? {};\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\n return (\n <Popup title={t(\"skins\")} onClose={onClose} width={520}>\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\n gap: 10,\n }}\n >\n <Card\n highlight={!wornID}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\n >\n <Icon glyph=\"hero\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\n {wornID ? (\n <Button\n size=\"sm\"\n busy={busy === \"@base\"}\n onClick={() => void act(\"@base\", \"base\")}\n >\n {t(\"wear\")}\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n )}\n </Card>\n {(view?.Skins ?? []).map((s, i) => {\n const def = defs[s.SkinID];\n const price = Object.values(s.PriceOptions ?? {})[0];\n return (\n <Card\n key={s.SkinID}\n highlight={s.IsWorn === true}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n ...stagger(i),\n }}\n >\n <Icon\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\n size={52}\n />\n <div style={{ ...outlined, fontSize: 14 }}>\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\n </div>\n {s.IsWorn ? (\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\n {t(\"worn\")}\n </span>\n ) : s.Owned ? (\n <Button\n size=\"sm\"\n disabled={s.MeetsRequirements === false}\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"wear\")}\n >\n {t(\"wear\")}\n </Button>\n ) : s.Purchasable && price ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n busy={busy === s.SkinID}\n onClick={() => void act(s.SkinID, \"buy\")}\n >\n <ResourceList\n lines={costLines(price.Cost as never)}\n size={16}\n />\n </Button>\n ) : (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"notForSale\")}\n </span>\n )}\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n"
52
+ },
53
+ {
54
+ "path": "i18n.ts",
55
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Heroes\",\n heroes: \"Heroes\",\n stats: \"Stats\",\n skins: \"Skins\",\n rank: \"Rank\",\n power: \"Power\",\n rankUp: \"Rank up\",\n maxRank: \"Max rank\",\n free: \"Free\",\n select: \"Select\",\n selected: \"Selected\",\n unlock: \"Unlock\",\n notForSale: \"Found in rewards\",\n equipment: \"Equipment\",\n equipBest: \"Equip best\",\n emptySlot: \"Empty\",\n slotLocked: \"Rank\",\n equip: \"Equip\",\n unequip: \"Take off\",\n levelUp: \"Level up\",\n maxLevel: \"Max level\",\n level: \"Level\",\n wornBy: \"Worn by\",\n bag: \"Your gear\",\n noGear: \"No gear yet — get some in the shop or from chests.\",\n noHeroes: \"No heroes in this game yet. Add them in LiveOps → Characters.\",\n upgrade: \"Upgrade\",\n statCapped: \"Rank up the hero to raise this cap\",\n needs: \"Needs\",\n where: \"Where it comes from\",\n base: \"Base\",\n fromLevel: \"Stat levels\",\n fromRank: \"Hero rank\",\n fromGear: \"Gear\",\n percent: \"Gear, %\",\n total: \"Total\",\n wear: \"Wear\",\n worn: \"Worn\",\n buy: \"Buy\",\n baseLook: \"Base look\",\n fits: \"Fits\",\n cannot: \"Cannot wear\",\n locked: \"Locked\",\n} as const;\n\nexport type Key = keyof typeof EN;\n\nexport const t = makeT<Key>(EN, {\n title: \"Герои\",\n heroes: \"Герои\",\n stats: \"Характеристики\",\n skins: \"Облики\",\n rank: \"Ранг\",\n power: \"Сила\",\n rankUp: \"Повысить ранг\",\n maxRank: \"Максимальный ранг\",\n free: \"Бесплатно\",\n select: \"Выбрать\",\n selected: \"Выбран\",\n unlock: \"Открыть\",\n notForSale: \"Выпадает в наградах\",\n equipment: \"Снаряжение\",\n equipBest: \"Надеть лучшее\",\n emptySlot: \"Пусто\",\n slotLocked: \"Ранг\",\n equip: \"Надеть\",\n unequip: \"Снять\",\n levelUp: \"Улучшить\",\n maxLevel: \"Макс. уровень\",\n level: \"Уровень\",\n wornBy: \"Надето на\",\n bag: \"Ваше снаряжение\",\n noGear: \"Снаряжения пока нет — оно есть в магазине и сундуках.\",\n noHeroes: \"Героев пока нет. Добавьте их в LiveOps → Characters.\",\n upgrade: \"Улучшить\",\n statCapped: \"Повысьте ранг героя, чтобы поднять предел\",\n needs: \"Нужно\",\n where: \"Откуда число\",\n base: \"База\",\n fromLevel: \"Уровни характеристики\",\n fromRank: \"Ранг героя\",\n fromGear: \"Снаряжение\",\n percent: \"Снаряжение, %\",\n total: \"Итого\",\n wear: \"Надеть\",\n worn: \"Надет\",\n buy: \"Купить\",\n baseLook: \"Обычный облик\",\n fits: \"Подходит\",\n cannot: \"Нельзя надеть\",\n locked: \"Закрыто\",\n});\n"
56
+ },
57
+ {
58
+ "path": "index.ts",
59
+ "content": "// @idosgames/mod-character-ui — Heroes screen for any game: the hero roster, ranks and stats with their costs, equipment slots with the best-gear button, item levels and skins.\n\nexport { characterUiModule } from \"./module\";\n"
60
+ },
61
+ {
62
+ "path": "model.test.ts",
63
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { itemDefinitions } from \"@idosgames/react/ui\";\nimport {\n bestLoadout,\n breakdown,\n fitProblem,\n gearPieces,\n heroesBadge,\n itemLevelCost,\n mergeByKey,\n rankCost,\n resolveLadder,\n resolveSlots,\n resolveStats,\n roster,\n statCost,\n statMaxLevel,\n statValue,\n unmetRequirements,\n type CharacterSection,\n type ItemDefWithGear,\n} from \"./model\";\n\n// The numbers the heroes screen shows before the player taps, checked against the module's own demo\n// heroes (title-seed.json, played through for real by the engine's ShowcaseHeroesTests). The gear\n// lives in the showcase's shared Item section — a small copy of it is inlined here, because a module\n// is copied into a project on its own and cannot read a sibling's seed.\n\nconst section = seed.Character as unknown as CharacterSection;\nconst items = itemDefinitions({\n Catalogs: {\n Item: {\n Items: {\n iron_sword: {\n ItemID: \"iron_sword\",\n ItemClass: \"Weapon\",\n IsStackable: false,\n Metadata: { RarityID: \"Rare\" },\n Tags: [\"weapon\"],\n Equipment: { AllowedSlotIDs: [\"Weapon\"] },\n Stats: { FlatBonuses: { Attack: 8 }, Power: 20 },\n Upgrade: {\n MaxLevel: 5,\n PriceOptions: {\n gold: {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 60 },\n ],\n },\n },\n },\n },\n CostCurve: { Shape: \"PerStepRate\", PerStepRate: 0.5 },\n FlatBonusCurve: { Shape: \"PerStepRate\", PerStepRate: 0.2 },\n PowerCurve: { Shape: \"PerStepRate\", PerStepRate: 0.2 },\n },\n },\n golden_crown: {\n ItemID: \"golden_crown\",\n ItemClass: \"Helmet\",\n IsStackable: false,\n Metadata: { RarityID: \"Legendary\" },\n Tags: [\"crown\"],\n Equipment: { AllowedSlotIDs: [\"Head\"] },\n Stats: { FlatBonuses: { Health: 40 }, Power: 60 },\n },\n },\n },\n },\n} as never) as Map<string, ItemDefWithGear>;\n\ndescribe(\"roster\", () => {\n it(\"the knight is open, archer and mage are for gems, in roster order\", () => {\n const list = roster(section, {});\n expect(list.map((h) => [h.id, h.state])).toEqual([\n [\"Knight\", \"available\"],\n [\"Archer\", \"locked\"],\n [\"Mage\", \"locked\"],\n ]);\n expect(list[1]?.unlockPrice).toEqual([\n { kind: \"currency\", id: \"GE\", amount: 20 },\n ]);\n expect(roster(section, { Archer: { Level: 1, Power: 5 } })[1]?.state).toBe(\n \"owned\",\n );\n });\n});\n\ndescribe(\"stats and ranks\", () => {\n const knight = section.Definitions!.Knight!;\n const stats = resolveStats(section, knight);\n const attack = stats.find((s) => s.id === \"Attack\")!;\n\n it(\"reads the knight's stats with their prices\", () => {\n expect(stats.map((s) => s.id)).toEqual([\"Attack\", \"Defense\", \"Health\"]);\n expect(statCost(attack, 1)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 50 },\n ]);\n expect(statCost(attack, 2)[0]?.amount).toBe(75);\n });\n\n it(\"values grow with the stat level and the rank; the cap grows with the rank\", () => {\n expect(statValue(attack, 0, 1, knight)).toBe(12);\n expect(statValue(attack, 1, 1, knight)).toBeGreaterThan(12);\n expect(statValue(attack, 0, 2, knight)).toBeGreaterThan(\n statValue(attack, 0, 1, knight),\n );\n expect(statMaxLevel(attack, 1, knight)).toBe(5);\n expect(statMaxLevel(attack, 2, knight)).toBe(10);\n });\n\n it(\"rank 1 → 2 is the first paid rank, the ceiling is 5\", () => {\n const ladder = resolveLadder(section, knight);\n expect(rankCost(ladder, 1)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 200 },\n ]);\n expect(rankCost(ladder, 2)?.[0]?.amount).toBe(320);\n expect(rankCost(ladder, 5)).toBeNull();\n expect(rankCost(null, 1)).toBeNull();\n });\n\n it(\"the archer's crit needs attack first\", () => {\n const crit = resolveStats(section, section.Definitions!.Archer!).find(\n (s) => s.id === \"Crit\",\n )!;\n expect(unmetRequirements(crit, {})).toEqual([\n { statID: \"Attack\", level: 1 },\n ]);\n expect(unmetRequirements(crit, { Attack: 1 })).toEqual([]);\n });\n\n it(\"presets merge under the inline block, Remove drops keys\", () => {\n expect(mergeByKey({ a: 1, b: 2 }, { b: 3, c: 4 }, [\"a\"])).toEqual({\n b: 3,\n c: 4,\n });\n expect(mergeByKey(null, { x: 1 }, null)).toEqual({ x: 1 });\n });\n});\n\ndescribe(\"equipment\", () => {\n const knight = section.Definitions!.Knight!;\n const slots = resolveSlots(section, knight);\n const pieces = gearPieces(\n {\n s1: { ItemID: \"iron_sword\", Level: 1 },\n c1: { ItemID: \"golden_crown\", Level: 1 },\n p1: { ItemID: \"potion\" },\n },\n items,\n );\n\n it(\"slots of the knight; the head opens at rank 2\", () => {\n expect(slots.map((s) => [s.id, s.minRank])).toEqual([\n [\"Weapon\", 0],\n [\"Offhand\", 0],\n [\"Head\", 2],\n ]);\n expect(pieces.map((p) => p.itemID)).toEqual([\"iron_sword\", \"golden_crown\"]);\n });\n\n it(\"checks both sides of the equip matrix\", () => {\n const head = slots.find((s) => s.id === \"Head\")!;\n const weapon = slots.find((s) => s.id === \"Weapon\")!;\n const crown = pieces.find((p) => p.itemID === \"golden_crown\")!;\n expect(fitProblem(crown, weapon, \"Knight\", 2, items)).toBe(\"slot\");\n expect(fitProblem(crown, head, \"Knight\", 1, items)).toBe(\"rank\");\n expect(fitProblem(crown, head, \"Knight\", 2, items)).toBeNull();\n });\n\n it(\"'Equip best' fills what fits and skips what is already best\", () => {\n expect(bestLoadout(\"Knight\", 1, slots, pieces, {}, items)).toEqual([\n { SlotID: \"Weapon\", ItemInstanceID: \"s1\" },\n ]);\n expect(bestLoadout(\"Knight\", 2, slots, pieces, {}, items)).toHaveLength(2);\n expect(\n bestLoadout(\n \"Knight\",\n 1,\n slots,\n pieces,\n { Weapon: { ItemInstanceID: \"s1\" } },\n items,\n ),\n ).toEqual([]);\n });\n\n it(\"item level price grows with the target level and stops at the cap\", () => {\n expect(itemLevelCost(items.get(\"iron_sword\"), 1)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 90 },\n ]);\n expect(itemLevelCost(items.get(\"iron_sword\"), 5)).toBeNull();\n expect(itemLevelCost(items.get(\"golden_crown\"), 1)).toBeNull();\n });\n\n it(\"the parts of a stat sum to its total\", () => {\n const attack = resolveStats(section, knight).find(\n (s) => s.id === \"Attack\",\n )!;\n const b = breakdown(attack, 2, 2, knight, [\n { def: items.get(\"iron_sword\"), level: 1 },\n ]);\n expect(b.fromGear).toBe(8);\n expect(b.base + b.fromLevel + b.fromRank + b.fromGear).toBeCloseTo(b.total);\n });\n\n it(\"badge: unlockable and rankable heroes, plus better gear\", () => {\n const list = roster(section, { Knight: { Level: 1 } });\n const rich = () => true;\n expect(\n heroesBadge(list, rich, (e) => resolveLadder(section, e.def), 1),\n ).toBe(4);\n expect(\n heroesBadge(\n list,\n () => false,\n (e) => resolveLadder(section, e.def),\n 0,\n ),\n ).toBe(0);\n });\n});\n"
64
+ },
65
+ {
66
+ "path": "model.ts",
67
+ "content": "// Pure helpers of the heroes screen (Unity IdleRPG/Runtime/Character + Equipment): the roster,\n// merged stats/ladder/slots (preset base + inline override, Remove drops keys — the server's rule),\n// prices of ranks, stats and item levels, which gear fits a slot, the best loadout, and where a\n// stat's number comes from. No React, no client calls — covered by model.test.ts.\n//\n// The server is the authority on every number: these only drive what the screen shows before the\n// player taps, and a rejected action is surfaced as the server's error.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Config views ─────────────────────────────────────────────────────────────────────────────\n\ntype PriceOptionsView =\n | Record<string, { OptionID?: string | null; Cost?: unknown } | null>\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n Description?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptionsView;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface SlotRuleView {\n SlotID?: string;\n MinCharacterLevel?: number | null;\n AllowedItemTags?: string[] | null;\n AllowedRarityIDs?: string[] | null;\n MinItemLevel?: number | null;\n MaxItemLevel?: number | null;\n}\n\nexport interface RankLadderView {\n MaxRank?: number | null;\n FirstPaidRank?: number | null;\n PriceOptions?: PriceOptionsView;\n CostCurve?: ScalarCurveSpec | null;\n}\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\nexport interface SkinDefView {\n SkinID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n AssetPaths?: Record<string, string> | null;\n } | null;\n Item?: { CatalogID?: string | null; ItemID?: string | null } | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n SortOrder?: number | null;\n AssetPaths?: Record<string, string> | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: {\n UnlockedByDefault?: boolean | null;\n PriceOptions?: PriceOptionsView;\n } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankLadder?: RankLadderView | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Equipment?: { Slots?: Record<string, SlotRuleView | null> | null } | null;\n Skins?: {\n DefaultSkinID?: string | null;\n Definitions?: Record<string, SkinDefView | null> | null;\n } | null;\n Presets?: {\n Stats?: Binding | null;\n Levels?: Binding | null;\n Equipment?: Binding | null;\n } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n Levels?: Record<\n string,\n { RankLadder?: RankLadderView | null } | null\n > | null;\n Equipment?: Record<\n string,\n {\n Equipment?: {\n Slots?: Record<string, SlotRuleView | null> | null;\n } | null;\n } | null\n > | null;\n } | null;\n}\n\n// ── State views ──────────────────────────────────────────────────────────────────────────────\n\nexport interface CharacterModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n { ItemID?: string | null; ItemInstanceID?: string | null } | null\n > | null;\n}\n\nexport interface InstanceView {\n ItemID?: string;\n Level?: number | null;\n EquippedSlot?: { CharacterID?: string | null; SlotID?: string | null } | null;\n}\n\nexport interface ItemDefWithGear extends ItemDefinitionView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n Power?: number | null;\n } | null;\n Equipment?: {\n MinCharacterLevel?: number | null;\n AllowedSlotIDs?: string[] | null;\n AllowedCharacterIDs?: string[] | null;\n } | null;\n Upgrade?: {\n MaxLevel?: number | null;\n PriceOptions?: PriceOptionsView;\n CostCurve?: ScalarCurveSpec | null;\n FlatBonusCurve?: ScalarCurveSpec | null;\n PowerCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n/** The reserved equipment key of the worn skin (SKIN_SLOT in @idosgames/core). */\nexport const SKIN_KEY = \"@skin\";\n\n// ── Presets ──────────────────────────────────────────────────────────────────────────────────\n\n/** Preset base + inline override/add by key, Remove drops keys. No preset → inline as-is. */\nexport function mergeByKey<T>(\n preset: Record<string, T | null> | null | undefined,\n inline: Record<string, T | null> | null | undefined,\n remove: readonly string[] | null | undefined,\n): Record<string, T> {\n const out: Record<string, T> = {};\n for (const [k, v] of Object.entries(preset ?? {})) if (v) out[k] = v;\n for (const [k, v] of Object.entries(inline ?? {})) if (v) out[k] = v;\n for (const k of remove ?? []) delete out[k];\n return out;\n}\n\n/** First way to pay → lines (\"default cost\", like the server's PriceOptionSelector.DefaultCost). */\nexport function firstPrice(options: PriceOptionsView): ResourceLine[] {\n const first = Object.values(options ?? {}).find((o) => o);\n return first ? costLines(first.Cost as never) : [];\n}\n\nfunction scale(lines: ResourceLine[], multiplier: number): ResourceLine[] {\n return lines.map((l) => ({\n ...l,\n amount: roundAmount(l.amount * multiplier),\n }));\n}\n\n// ── Roster ───────────────────────────────────────────────────────────────────────────────────\n\nexport type HeroState = \"owned\" | \"available\" | \"locked\";\n\nexport interface RosterEntry {\n id: string;\n def: CharacterDefView;\n model: CharacterModelView | undefined;\n state: HeroState;\n rank: number;\n power: number;\n unlockPrice: ResourceLine[];\n}\n\n/** Heroes in roster order (SortOrder, then id), each with its state for this player. */\nexport function roster(\n section: CharacterSection | undefined,\n owned:\n Record<string, CharacterModelView | null | undefined> | null | undefined,\n): RosterEntry[] {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) => ({ id: def?.CharacterID ?? id, def }))\n .filter((x): x is { id: string; def: CharacterDefView } => !!x.def)\n .sort(\n (a, b) =>\n Number(a.def.Identity?.SortOrder ?? 0) -\n Number(b.def.Identity?.SortOrder ?? 0) || a.id.localeCompare(b.id),\n )\n .map(({ id, def }) => {\n const model = owned?.[id] ?? undefined;\n const rank = Number(model?.Level ?? 0);\n const state: HeroState =\n rank >= 1\n ? \"owned\"\n : def.Unlock?.UnlockedByDefault\n ? \"available\"\n : \"locked\";\n return {\n id,\n def,\n model,\n state,\n rank: Math.max(rank, state === \"available\" ? 1 : 0),\n power: Number(model?.Power ?? 0),\n unlockPrice: firstPrice(def.Unlock?.PriceOptions),\n };\n });\n}\n\n// ── Stats ────────────────────────────────────────────────────────────────────────────────────\n\nexport interface StatView {\n id: string;\n name: string;\n description: string;\n maxLevel: number;\n base: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n price: ResourceLine[];\n requirements: Array<{ statID: string; level: number }>;\n}\n\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatView[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n return Object.entries(mergeByKey(preset, def.Stats, binding?.Remove)).map(\n ([id, s]) => ({\n id: s.StatID ?? id,\n name: s.DisplayName || id,\n description: s.Description || \"\",\n maxLevel: Number(s.MaxLevel ?? 0),\n base: Number(s.BaseStatValue ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: firstPrice(s.PriceOptions),\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n }),\n );\n}\n\n/**\n * Stat value at stat-level `level` for a hero of rank `rank`: the stat's ValueCurve from 0, its\n * RankCurve and the hero's RankStatCurve from 1 — the order the server composes them in.\n */\nexport function statValue(\n stat: StatView,\n level: number,\n rank: number,\n def?: CharacterDefView,\n): number {\n const own = evaluateCurve(\n stat.valueCurve ?? undefined,\n stat.base,\n Math.max(0, level),\n 0,\n );\n const byRank = evaluateCurve(\n stat.rankCurve ?? undefined,\n own,\n Math.max(1, rank),\n 1,\n );\n return evaluateCurve(\n def?.RankStatCurve ?? undefined,\n byRank,\n Math.max(1, rank),\n 1,\n );\n}\n\n/** The effective cap of a stat at a rank: MaxLevel through the hero's StatMaxLevelCurve. */\nexport function statMaxLevel(\n stat: StatView,\n rank: number,\n def?: CharacterDefView,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(\n def?.StatMaxLevelCurve ?? undefined,\n stat.maxLevel,\n Math.max(1, rank),\n 1,\n ),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs (base price through CostCurve, rounded up once). */\nexport function statCost(stat: StatView, nextLevel: number): ResourceLine[] {\n return scale(\n stat.price,\n curveMultiplier(stat.costCurve ?? undefined, Math.max(1, nextLevel), 1),\n );\n}\n\n/** Requirements of a stat the hero has not met yet. */\nexport function unmetRequirements(\n stat: StatView,\n levels: Record<string, number> | null | undefined,\n): Array<{ statID: string; level: number }> {\n return stat.requirements.filter(\n (r) => Number(levels?.[r.statID] ?? 0) < r.level,\n );\n}\n\n// ── Ranks ────────────────────────────────────────────────────────────────────────────────────\n\nexport function resolveLadder(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): RankLadderView | null {\n if (!def) return null;\n if (def.RankLadder) return def.RankLadder;\n const id = def.Presets?.Levels?.PresetID;\n return id ? (section?.Presets?.Levels?.[id]?.RankLadder ?? null) : null;\n}\n\n/** What the next rank costs; `null` — the hero cannot rank up (no ladder, or at the ceiling). */\nexport function rankCost(\n ladder: RankLadderView | null,\n currentRank: number,\n): ResourceLine[] | null {\n if (!ladder) return null;\n const next = currentRank + 1;\n if (ladder.MaxRank && next > ladder.MaxRank) return null;\n const firstPaid = Math.max(1, Number(ladder.FirstPaidRank ?? 1));\n if (next < firstPaid) return [];\n return scale(\n firstPrice(ladder.PriceOptions),\n curveMultiplier(ladder.CostCurve ?? undefined, next, firstPaid),\n );\n}\n\n// ── Equipment ────────────────────────────────────────────────────────────────────────────────\n\nexport interface SlotView {\n id: string;\n minRank: number;\n rule: SlotRuleView;\n}\n\n/** Gear slots of a hero (the reserved \"@…\" keys — the worn skin — are not slots). */\nexport function resolveSlots(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): SlotView[] {\n if (!def) return [];\n const binding = def.Presets?.Equipment;\n const preset = binding?.PresetID\n ? section?.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\n : undefined;\n return Object.entries(\n mergeByKey(preset, def.Equipment?.Slots, binding?.Remove),\n )\n .filter(([id]) => !id.startsWith(\"@\"))\n .map(([id, rule]) => ({\n id: rule.SlotID ?? id,\n minRank: Number(rule.MinCharacterLevel ?? 0),\n rule,\n }));\n}\n\nexport interface GearPiece {\n instanceID: string;\n itemID: string;\n level: number;\n equippedBy: { characterID: string; slotID: string } | null;\n}\n\n/** Every gear instance the player owns (unstackable items that declare equip rules). */\nexport function gearPieces(\n instances: Record<string, InstanceView | null> | null | undefined,\n items: Map<string, ItemDefWithGear>,\n): GearPiece[] {\n return Object.entries(instances ?? {})\n .filter(([, i]) => i?.ItemID && items.get(i.ItemID)?.Equipment)\n .map(([id, i]) => ({\n instanceID: id,\n itemID: i!.ItemID!,\n level: Number(i!.Level ?? 1) || 1,\n equippedBy:\n i!.EquippedSlot?.CharacterID && i!.EquippedSlot.SlotID\n ? {\n characterID: i!.EquippedSlot.CharacterID,\n slotID: i!.EquippedSlot.SlotID,\n }\n : null,\n }));\n}\n\n/** Why a piece cannot go into a slot of this hero, or null when it fits (both sides of the matrix). */\nexport function fitProblem(\n piece: GearPiece,\n slot: SlotView,\n heroID: string,\n rank: number,\n items: Map<string, ItemDefWithGear>,\n): \"slot\" | \"rank\" | \"rarity\" | \"tags\" | \"level\" | \"hero\" | null {\n const def = items.get(piece.itemID);\n const allowed = def?.Equipment?.AllowedSlotIDs ?? [];\n if (allowed.length > 0 && !allowed.includes(slot.id)) return \"slot\";\n if (\n def?.Equipment?.AllowedCharacterIDs?.length &&\n !def.Equipment.AllowedCharacterIDs.includes(heroID)\n )\n return \"hero\";\n if (\n rank < slot.minRank ||\n rank < Number(def?.Equipment?.MinCharacterLevel ?? 0)\n )\n return \"rank\";\n const r = slot.rule;\n if (\n r.AllowedRarityIDs?.length &&\n !r.AllowedRarityIDs.includes(def?.Metadata?.RarityID ?? \"\")\n )\n return \"rarity\";\n if (\n r.AllowedItemTags?.length &&\n !(def?.Tags ?? []).some((t) => r.AllowedItemTags!.includes(t))\n )\n return \"tags\";\n if ((r.MinItemLevel ?? 0) > 0 && piece.level < (r.MinItemLevel ?? 0))\n return \"level\";\n if ((r.MaxItemLevel ?? 0) > 0 && piece.level > (r.MaxItemLevel ?? 0))\n return \"level\";\n return null;\n}\n\n/** A comparable strength of a piece: its Power by level, else the sum of its flat bonuses. */\nexport function gearScore(\n piece: GearPiece,\n items: Map<string, ItemDefWithGear>,\n): number {\n const def = items.get(piece.itemID);\n const power = Number(def?.Stats?.Power ?? 0);\n if (power > 0)\n return (\n power *\n curveMultiplier(def?.Upgrade?.PowerCurve ?? undefined, piece.level, 1)\n );\n const flat = Object.values(def?.Stats?.FlatBonuses ?? {}).reduce(\n (s, n) => s + Number(n || 0),\n 0,\n );\n return (\n flat *\n curveMultiplier(\n def?.Upgrade?.FlatBonusCurve ?? undefined,\n piece.level,\n 1,\n ) +\n (4 - Math.min(4, rarityRank(def?.Metadata?.RarityID))) * 0.01\n );\n}\n\n/**\n * \"Equip best\" (Unity EquipmentAutoAssign): per unlocked slot, the strongest fitting piece not worn\n * by another hero and not taken by an earlier slot. Returns only the slots that would CHANGE.\n */\nexport function bestLoadout(\n heroID: string,\n rank: number,\n slots: SlotView[],\n pieces: GearPiece[],\n current: CharacterModelView[\"Equipment\"],\n items: Map<string, ItemDefWithGear>,\n): Array<{ SlotID: string; ItemInstanceID: string }> {\n const taken = new Set<string>();\n const changes: Array<{ SlotID: string; ItemInstanceID: string }> = [];\n for (const slot of slots) {\n const candidates = pieces\n .filter((p) => !taken.has(p.instanceID))\n .filter((p) => !p.equippedBy || p.equippedBy.characterID === heroID)\n .filter((p) => fitProblem(p, slot, heroID, rank, items) === null)\n .sort(\n (a, b) =>\n gearScore(b, items) - gearScore(a, items) ||\n a.instanceID.localeCompare(b.instanceID),\n );\n const best = candidates[0];\n if (!best) continue;\n taken.add(best.instanceID);\n const worn = current?.[slot.id]?.ItemInstanceID ?? null;\n const wornPiece = worn\n ? pieces.find((p) => p.instanceID === worn)\n : undefined;\n if (worn === best.instanceID) continue;\n if (wornPiece && gearScore(wornPiece, items) >= gearScore(best, items)) {\n taken.add(worn!);\n continue;\n }\n changes.push({ SlotID: slot.id, ItemInstanceID: best.instanceID });\n }\n return changes;\n}\n\n/** What raising an item instance TO `nextLevel` costs; null — at its cap or not upgradable. */\nexport function itemLevelCost(\n def: ItemDefWithGear | undefined,\n currentLevel: number,\n): ResourceLine[] | null {\n const up = def?.Upgrade;\n if (!up) return null;\n const next = currentLevel + 1;\n if ((up.MaxLevel ?? 0) > 0 && next > (up.MaxLevel ?? 0)) return null;\n return scale(\n firstPrice(up.PriceOptions),\n curveMultiplier(up.CostCurve ?? undefined, next, 1),\n );\n}\n\n/** Flat bonuses of a piece at its level (the item's FlatBonusCurve), by stat id. */\nexport function pieceBonuses(\n def: ItemDefWithGear | undefined,\n level: number,\n): Record<string, number> {\n const k = curveMultiplier(\n def?.Upgrade?.FlatBonusCurve ?? undefined,\n Math.max(1, level),\n 1,\n );\n return Object.fromEntries(\n Object.entries(def?.Stats?.FlatBonuses ?? {}).map(([id, n]) => [\n id,\n Number(n || 0) * k,\n ]),\n );\n}\n\n// ── \"Where does this number come from\" (Unity StatBreakdownPopup) ────────────────────────────\n\nexport interface Breakdown {\n base: number;\n fromLevel: number;\n fromRank: number;\n fromGear: number;\n percent: number;\n total: number;\n}\n\n/**\n * The parts of a stat: the base, what its own levels add, what the rank adds, flat bonuses of the\n * worn gear, then their percent bonuses — added in that order, so the parts sum to the total.\n */\nexport function breakdown(\n stat: StatView,\n level: number,\n rank: number,\n def: CharacterDefView | undefined,\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>,\n): Breakdown {\n const base = stat.base;\n const withLevel = evaluateCurve(\n stat.valueCurve ?? undefined,\n base,\n Math.max(0, level),\n 0,\n );\n const withRank = statValue(stat, level, rank, def);\n const fromGear = worn.reduce(\n (s, w) => s + (pieceBonuses(w.def, w.level)[stat.id] ?? 0),\n 0,\n );\n const percent = worn.reduce(\n (s, w) => s + Number(w.def?.Stats?.PercentBonuses?.[stat.id] ?? 0),\n 0,\n );\n const subtotal = withRank + fromGear;\n const total = subtotal * (1 + percent / 100);\n return {\n base,\n fromLevel: withLevel - base,\n fromRank: withRank - withLevel,\n fromGear,\n percent,\n total,\n };\n}\n\n// ── Badge ────────────────────────────────────────────────────────────────────────────────────\n\n/** Badge of the heroes screen: heroes that can be unlocked or ranked up right now, plus better gear. */\nexport function heroesBadge(\n entries: RosterEntry[],\n canPay: (lines: ResourceLine[]) => boolean,\n ladderOf: (e: RosterEntry) => RankLadderView | null,\n betterGear: number,\n): number {\n let n = 0;\n for (const e of entries) {\n if (\n e.state === \"locked\" &&\n e.unlockPrice.length > 0 &&\n canPay(e.unlockPrice)\n )\n n++;\n if (e.state !== \"locked\") {\n const cost = rankCost(ladderOf(e), e.rank);\n if (cost && cost.length > 0 && canPay(cost)) n++;\n }\n }\n return n + (betterGear > 0 ? 1 : 0);\n}\n"
68
+ },
69
+ {
70
+ "path": "module.ts",
71
+ "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 canAfford,\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { makeCharacterScreen } from \"./CharacterScreen\";\nimport {\n bestLoadout,\n gearPieces,\n heroesBadge,\n resolveLadder,\n resolveSlots,\n roster,\n type CharacterModelView,\n type CharacterSection,\n type InstanceView,\n type ItemDefWithGear,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Heroes as a feature of the game (the Hero button of the Unity project). The badge counts heroes\n// that can be unlocked or ranked up right now, plus \"better gear is available\".\nexport const characterUiModule: Module = defineModule({\n id: \"character-ui\",\n meta: { name: \"Heroes\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"character\",\n label: t(\"title\"),\n icon: \"hero\",\n group: \"economy\",\n order: 20,\n primary: true,\n Screen: makeCharacterScreen(ctx.features),\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: \"character\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function HeroesBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const section = configSection<CharacterSection>(client, \"Character\");\n if (!section) return features.setBadge(\"character\", 0);\n const state = client.data.user.state;\n const owned = (state?.Character?.Characters ?? {}) as Record<\n string,\n CharacterModelView\n >;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const items = itemDefinitions(\n configSection<ItemSection>(client, \"Item\"),\n ) as Map<string, ItemDefWithGear>;\n const pieces = gearPieces(\n (\n state?.InventoryV2 as\n | { UnstackableItems?: Record<string, InstanceView | null> }\n | undefined\n )?.UnstackableItems,\n items,\n );\n const list = roster(section, owned);\n const better = list\n .filter((h) => h.state === \"owned\")\n .reduce(\n (n, h) =>\n n +\n bestLoadout(\n h.id,\n h.rank,\n resolveSlots(section, h.def),\n pieces,\n h.model?.Equipment,\n items,\n ).length,\n 0,\n );\n features.setBadge(\n \"character\",\n heroesBadge(\n list,\n (lines) => canAfford(lines, balances),\n (e) => resolveLadder(section, e.def),\n better,\n ),\n );\n };\n update();\n void client.character.getUserCharacters().then(update);\n void client.user.getUserInventory().then(update);\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
72
+ },
73
+ {
74
+ "path": "module.meta.json",
75
+ "content": "{\n \"id\": \"character-ui\",\n \"type\": \"feature\",\n \"summary\": \"Heroes screen for any game: roster, ranks and stats with their costs, equipment slots with 'Equip best', item levels and skins.\",\n \"description\": \"A ready heroes screen (the Hero button of the Unity SDK) for ANY game, built on the @idosgames/react/ui kit and client.character. The screen is the selected hero's equipment hub: portrait, class and rarity, rank and server-computed Power, 'Rank up' with its price, gear slots (locked ones show the rank they open at), 'Equip best' when better gear fits, and the gear the player owns by class. Everything detailed is a popup: the hero list (select, unlock for currency), the stats (level/cap bars, upgrade prices, requirements, and 'where this number comes from' — base, stat levels, rank, gear flat and percent bonuses), a slot's fitting gear, a piece of gear (equip / take off / level up) and skins (wear, buy). Prices and caps follow the title's curves (rank ladder, stat cost curve, StatMaxLevelCurve, item Upgrade); the server re-checks everything. Registers the 'character' feature; the inventory hands equipment to it with ctx.features.open('character', { itemInstanceID }). Its badge counts heroes to unlock or rank up and better gear. Ships title-seed.json: three heroes (Knight free, Archer and Mage for gems) with stats, ranks and slots.\",\n \"provides\": [\n \"hero roster with unlock for currency\",\n \"rank up with price by the rank ladder\",\n \"stat upgrades with costs, caps and requirements\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\"\n ],\n \"tags\": [\n \"heroes\",\n \"characters\",\n \"rpg\",\n \"stats\",\n \"equipment\",\n \"gear\",\n \"skins\",\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"
76
+ }
77
+ ]
78
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "id": "chat-ui",
3
+ "meta": {
4
+ "name": "Chat",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "summary": "Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.",
11
+ "description": "A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.",
12
+ "provides": [
13
+ "chat channels as tabs",
14
+ "private chats",
15
+ "message feed with earlier messages",
16
+ "slow mode countdown",
17
+ "report, ignore and write privately",
18
+ "join and leave channels",
19
+ "chat badge",
20
+ "demo channels"
21
+ ],
22
+ "tags": [
23
+ "chat",
24
+ "social",
25
+ "messages",
26
+ "dm",
27
+ "moderation",
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.5.0",
40
+ "@idosgames/react": "0.3.0",
41
+ "react": "19.2.7"
42
+ },
43
+ "contentHash": "3e02bb18c0e156cf14736377a4c0bde15149eab92138d7f0252a906377c53307",
44
+ "files": [
45
+ {
46
+ "path": "ChatScreen.tsx",
47
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport type {\n ChatCursor,\n ChatReportReason,\n IDosGamesClient,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n IconButton,\n Popup,\n Spinner,\n Tabs,\n errorText,\n outlined,\n panel,\n useCatalog,\n useNow,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n type ChannelView,\n type MessageView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chat (Unity V2/Runtime/UI/Chat): the title's channels as tabs plus \"Private\" (1-on-1 chats with\n// friends). One poll covers every room and runs ONLY while this screen is open, at the interval the\n// server sets; rooms are marked read when the player leaves them. Message actions (report, ignore,\n// write privately) and joining / leaving channels are popups. Another module opens a private chat\n// with features.open(\"chat\", { userID }).\n\ntype Room =\n | { kind: \"channel\"; channel: ChannelView }\n | { kind: \"direct\"; roomID: string; peerName: string };\ntype Conversation = {\n RoomID?: string | null;\n PeerUserID?: string | null;\n PeerProfile?: { Username?: string | null } | null;\n UnreadCount?: number | null;\n LastMessage?: MessageView | null;\n};\nconst DIRECT = \"__direct\";\ntype ChatDefs = {\n Direct?: { IsEnabled?: boolean | null } | null;\n Channels?: Parameters<typeof channelChoices>[0];\n};\n\n/** Badge once per session (a closed chat costs nothing: no background polling). */\nexport function makeChatWatcher(features: FeatureRegistry) {\n return function ChatWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n void client.chat\n .getChannels()\n .then(\n (r) =>\n r.ok && features.setBadge(\"chat\", chatBadge(r.data.Channels ?? [])),\n );\n }, [client]);\n return null;\n };\n}\n\nexport function makeChatScreen(features: FeatureRegistry) {\n return function ChatScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [channels, setChannels] = useState<ChannelView[] | null>(null);\n const [interval, setIntervalSec] = useState(5);\n const [mutedUntil, setMutedUntil] = useState<string | null>(null);\n const [tab, setTab] = useState<string | null>(null);\n const [room, setRoom] = useState<Room | null>(null);\n const [feeds, setFeeds] = useState<Record<string, MessageView[]>>({});\n const [unread, setUnread] = useState<Record<string, number | null>>({});\n const [managing, setManaging] = useState(false);\n // The Chat section is not part of the client config — the definitions come from their own call.\n const [defs, setDefs] = useState<ChatDefs | null>(null);\n const roomRef = useRef<Room | null>(null);\n roomRef.current = room;\n\n const loadChannels = useCallback(async () => {\n const res = await client.chat.getChannels();\n if (!res.ok) return setChannels([]);\n const list = orderedChannels(res.data.Channels ?? []);\n setChannels(list);\n setIntervalSec(Math.max(2, Number(res.data.PollIntervalSeconds ?? 5)));\n setMutedUntil(res.data.MutedUntil ?? null);\n setUnread(\n Object.fromEntries(\n list.map((c) => [c.RoomID ?? \"\", c.UnreadCount ?? null]),\n ),\n );\n features.setBadge(\"chat\", 0);\n return list;\n }, [client]);\n\n useEffect(() => {\n void client.chat\n .getChatDefinitions()\n .then((r) => setDefs(r.ok ? (r.data as ChatDefs) : {}));\n }, [client]);\n\n useEffect(() => {\n void loadChannels().then((list) => {\n const target = (args as { userID?: string } | undefined)?.userID;\n if (target)\n return void openDirect(client, target, toast, (r) => {\n setTab(DIRECT);\n setRoom(r);\n });\n const first = list?.[0];\n if (first) {\n setTab(first.ChannelID ?? null);\n setRoom({ kind: \"channel\", channel: first });\n }\n });\n }, [loadChannels]);\n\n // One poll for every room, only while the screen is open.\n useEffect(() => {\n let stopped = false;\n let timer: ReturnType<typeof setTimeout> | undefined;\n const tick = async () => {\n const res = await client.chat.poll();\n if (stopped) return;\n if (res.ok) {\n const current = roomID(roomRef.current);\n for (const r of res.data.Rooms ?? []) {\n if (!r.RoomID) continue;\n const msgs = r.Messages ?? [];\n if (msgs.length > 0)\n setFeeds((f) => ({\n ...f,\n [r.RoomID!]: mergeMessages(f[r.RoomID!] ?? [], msgs),\n }));\n if (r.RoomID !== current)\n setUnread((u) => ({\n ...u,\n [r.RoomID!]: r.UnreadCount ?? u[r.RoomID!] ?? null,\n }));\n }\n if (res.data.PollIntervalSeconds)\n setIntervalSec(Math.max(2, res.data.PollIntervalSeconds));\n }\n timer = setTimeout(() => void tick(), interval * 1000);\n };\n timer = setTimeout(() => void tick(), interval * 1000);\n return () => {\n stopped = true;\n if (timer) clearTimeout(timer);\n };\n }, [client, interval]);\n\n if (!channels || !defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const directEnabled = !!defs.Direct?.IsEnabled;\n if (channels.length === 0 && !directEnabled)\n return <EmptyState glyph=\"chat\" text={t(\"disabled\")} />;\n\n const pick = (id: string) => {\n setTab(id);\n if (id === DIRECT) return setRoom(null);\n const ch = channels.find((c) => c.ChannelID === id);\n if (ch) setRoom({ kind: \"channel\", channel: ch });\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs\n tabs={[\n ...channels.map((c) => ({\n id: c.ChannelID!,\n label: catalog.localize(c.DisplayNameKey ?? c.ChannelID),\n badge:\n Number(\n unreadLabel(unread[c.RoomID ?? \"\"]).replace(\"+\", \"\"),\n ) || undefined,\n })),\n ...(directEnabled ? [{ id: DIRECT, label: t(\"direct\") }] : []),\n ]}\n value={tab ?? \"\"}\n onChange={pick}\n />\n </div>\n <IconButton\n glyph=\"gear\"\n label={t(\"channels\")}\n onClick={() => setManaging(true)}\n size={36}\n tone=\"blue\"\n />\n </div>\n {mutedUntil && new Date(mutedUntil).getTime() > Date.now() ? (\n <div\n style={{\n ...panel,\n padding: 8,\n ...outlined,\n fontSize: 13,\n color: v.red,\n textAlign: \"center\",\n }}\n >\n {t(\"muted\")} {new Date(mutedUntil).toLocaleString()}\n </div>\n ) : null}\n {tab === DIRECT && !room ? (\n <Conversations onOpen={(r) => setRoom(r)} />\n ) : room ? (\n <RoomView\n key={roomID(room)}\n room={room}\n feed={feeds[roomID(room)] ?? []}\n setFeed={(fn) =>\n setFeeds((f) => ({\n ...f,\n [roomID(room)]: fn(f[roomID(room)] ?? []),\n }))\n }\n onBack={room.kind === \"direct\" ? () => setRoom(null) : undefined}\n onOpenDirect={(r) => {\n setTab(DIRECT);\n setRoom(r);\n }}\n directEnabled={directEnabled}\n />\n ) : null}\n {managing ? (\n <ChannelsPopup\n defs={defs}\n joined={channels.map((c) => c.ChannelID ?? \"\")}\n onClose={() => setManaging(false)}\n onChanged={() => void loadChannels()}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction roomID(room: Room | null): string {\n if (!room) return \"\";\n return room.kind === \"channel\" ? (room.channel.RoomID ?? \"\") : room.roomID;\n}\n\nasync function openDirect(\n client: IDosGamesClient,\n userID: string,\n toast: (text: string, kind?: \"error\") => void,\n open: (r: Room) => void,\n): Promise<void> {\n const res = await client.chat.openDirectChannel(userID);\n if (!res.ok || !res.data.RoomID)\n return toast(errorText(res.ok ? \"\" : res.error), \"error\");\n open({\n kind: \"direct\",\n roomID: res.data.RoomID,\n peerName: res.data.PeerProfile?.Username ?? userID,\n });\n}\n\nfunction RoomView({\n room,\n feed,\n setFeed,\n onBack,\n onOpenDirect,\n directEnabled,\n}: {\n room: Room;\n feed: MessageView[];\n setFeed: (fn: (prev: MessageView[]) => MessageView[]) => void;\n onBack?: () => void;\n onOpenDirect: (r: Room) => void;\n directEnabled: boolean;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const now = useNow();\n const id = roomID(room);\n const [older, setOlder] = useState<ChatCursor | null | undefined>(undefined);\n const [loading, setLoading] = useState(true);\n const [text, setText] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [lastSent, setLastSent] = useState<number | null>(null);\n const [picked, setPicked] = useState<MessageView | null>(null);\n const feedRef = useRef<HTMLDivElement>(null);\n const feedNow = useRef(feed);\n feedNow.current = feed;\n const me = client.auth.context?.userID;\n const channel = room.kind === \"channel\" ? room.channel : null;\n const maxLength = channel?.MaxLength ?? 500;\n\n // History first, then mark read up to what was drawn; on leaving, read up to the last message.\n useEffect(() => {\n let alive = true;\n void client.chat.getHistory(id).then((res) => {\n if (!alive) return;\n setLoading(false);\n if (!res.ok) return;\n const merged = mergeMessages(feedNow.current, res.data.Messages ?? []);\n setFeed(() => merged);\n setOlder(res.data.NextBefore ?? null);\n const cursor = lastCursor(id, merged);\n if (cursor) void client.chat.markRead(id, cursor);\n });\n return () => {\n alive = false;\n const cursor = lastCursor(id, feedNow.current);\n if (cursor) void client.chat.markRead(id, cursor);\n };\n }, [client, id]);\n\n // Keep the newest message in view.\n useEffect(() => {\n const el = feedRef.current;\n if (el) el.scrollTop = el.scrollHeight;\n }, [feed.length]);\n\n const loadOlder = async () => {\n if (!older) return;\n const res = await client.chat.getHistory(id, older);\n if (!res.ok) return;\n setFeed((prev) => mergeMessages(prev, res.data.Messages ?? []));\n setOlder(res.data.NextBefore ?? null);\n };\n\n const wait = slowModeLeft(lastSent, channel?.SlowModeSeconds, now);\n const message = sendable(text, maxLength);\n const canWrite = channel ? channel.CanWrite !== false : true;\n\n const send = async () => {\n if (!message || wait > 0) return;\n setBusy(true);\n const res = channel\n ? await client.chat.sendMessage(channel.ChannelID!, message)\n : await client.chat.sendDirect(id, message);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"tab\");\n setText(\"\");\n setLastSent(Date.now());\n // Render the SERVER's copy: moderation may have masked a word.\n if (res.data.Message)\n setFeed((prev) => mergeMessages(prev, [res.data.Message!]));\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gap: 8,\n height: \"min(70vh, 640px)\",\n minHeight: 320,\n }}\n >\n {onBack ? (\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <Button size=\"sm\" tone=\"grey\" onClick={onBack}>\n ← {t(\"back\")}\n </Button>\n <span style={{ ...outlined, fontSize: 15 }}>\n {room.kind === \"direct\" ? room.peerName : \"\"}\n </span>\n </div>\n ) : (\n <div />\n )}\n <div\n ref={feedRef}\n className=\"idos-scroll\"\n style={{\n ...panel,\n padding: 10,\n overflowY: \"auto\",\n display: \"grid\",\n alignContent: \"start\",\n gap: 6,\n }}\n >\n {older ? (\n <Button\n size=\"sm\"\n tone=\"grey\"\n onClick={() => void loadOlder()}\n style={{ justifySelf: \"center\" }}\n >\n {t(\"older\")}\n </Button>\n ) : null}\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : feed.length === 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n padding: 20,\n }}\n >\n {t(\"noMessages\")}\n </div>\n ) : null}\n {feed.map((m) => {\n const mine = m.SenderUserID === me;\n const system = isSystem(m);\n return (\n <div\n key={m.MessageID}\n style={{\n display: \"flex\",\n justifyContent: system\n ? \"center\"\n : mine\n ? \"flex-end\"\n : \"flex-start\",\n }}\n >\n <div\n onClick={!mine && !system ? () => setPicked(m) : undefined}\n style={{\n maxWidth: \"80%\",\n padding: \"6px 10px\",\n borderRadius: 12,\n background: system\n ? \"rgba(255,200,60,.18)\"\n : mine\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.1)\",\n cursor: !mine && !system ? \"pointer\" : undefined,\n overflowWrap: \"anywhere\",\n }}\n >\n {!mine ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: system ? v.gold : v.textDim,\n }}\n >\n {system ? t(\"system\") : authorName(m)}\n </div>\n ) : null}\n <div style={{ fontSize: 14, fontWeight: 600 }}>{m.Text}</div>\n <div\n style={{\n fontSize: 10,\n color: \"rgba(255,255,255,.6)\",\n textAlign: \"right\",\n }}\n >\n {m.SentAt\n ? new Date(m.SentAt).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"\"}\n </div>\n </div>\n </div>\n );\n })}\n </div>\n {canWrite ? (\n <div style={{ display: \"flex\", gap: 8 }}>\n <input\n value={text}\n maxLength={maxLength}\n onChange={(e) => setText(e.target.value)}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n placeholder={t(\"placeholder\")}\n style={{\n ...panel,\n flex: 1,\n minWidth: 0,\n padding: \"10px 12px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n }}\n data-tutorial-anchor=\"chat:input\"\n />\n <Button\n tone=\"green\"\n disabled={!message || wait > 0}\n busy={busy}\n onClick={() => void send()}\n >\n {wait > 0 ? `${wait}${t(\"sec\")}` : <Icon glyph=\"chat\" size={20} />}\n </Button>\n </div>\n ) : (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {channel?.WriteBlockedReason ?? \"—\"}\n </div>\n )}\n {picked ? (\n <MessagePopup\n message={picked}\n roomID={id}\n directEnabled={directEnabled}\n onOpenDirect={onOpenDirect}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Conversations({ onOpen }: { onOpen: (r: Room) => void }): ReactNode {\n const client = useIDosGamesClient();\n const [list, setList] = useState<Conversation[] | null>(null);\n useEffect(() => {\n void client.chat\n .getConversations()\n .then((r) =>\n setList(r.ok ? ((r.data.Conversations ?? []) as Conversation[]) : []),\n );\n }, [client]);\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"chat\" text={t(\"noConversations\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((c) => {\n const name = c.PeerProfile?.Username ?? c.PeerUserID ?? \"?\";\n return (\n <div\n key={c.RoomID}\n onClick={() =>\n c.RoomID &&\n onOpen({ kind: \"direct\", roomID: c.RoomID, peerName: name })\n }\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"user\" size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{name}</div>\n <div\n style={{\n fontSize: 12,\n color: v.textDim,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {c.LastMessage?.Text ?? \"\"}\n </div>\n </div>\n {unreadLabel(c.UnreadCount) ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n background: v.red,\n borderRadius: 10,\n padding: \"2px 7px\",\n }}\n >\n {unreadLabel(c.UnreadCount)}\n </span>\n ) : null}\n </div>\n );\n })}\n </div>\n );\n}\n\nconst REASONS: Array<{ id: ChatReportReason; label: Parameters<typeof t>[0] }> =\n [\n { id: \"Spam\", label: \"rSpam\" },\n { id: \"Abuse\", label: \"rAbuse\" },\n { id: \"Scam\", label: \"rScam\" },\n { id: \"Inappropriate\", label: \"rInappropriate\" },\n { id: \"Other\", label: \"rOther\" },\n ];\n\nfunction MessagePopup({\n message,\n roomID,\n directEnabled,\n onOpenDirect,\n onClose,\n}: {\n message: MessageView;\n roomID: string;\n directEnabled: boolean;\n onOpenDirect: (r: Room) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [reason, setReason] = useState<ChatReportReason>(\"Spam\");\n const [busy, setBusy] = useState<string | null>(null);\n const sender = message.SenderUserID!;\n const run = async (what: string, fn: () => Promise<void>) => {\n setBusy(what);\n await fn();\n setBusy(null);\n };\n return (\n <Popup title={authorName(message)} onClose={onClose} width={420}>\n <div\n style={{\n ...panel,\n padding: 10,\n fontSize: 14,\n fontWeight: 600,\n overflowWrap: \"anywhere\",\n }}\n >\n {message.Text}\n </div>\n {directEnabled ? (\n <Button\n tone=\"blue\"\n busy={busy === \"dm\"}\n onClick={() =>\n void run(\"dm\", async () => {\n await openDirect(client, sender, toast, (r) => {\n onOpenDirect(r);\n onClose();\n });\n })\n }\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"writePrivately\")}\n </Button>\n ) : null}\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"reason\")}\n </div>\n <Tabs\n tabs={REASONS.map((r) => ({ id: r.id, label: t(r.label) }))}\n value={reason}\n onChange={setReason}\n />\n <Button\n tone=\"red\"\n busy={busy === \"report\"}\n onClick={() =>\n void run(\"report\", async () => {\n const res = await client.chat.reportMessage(\n roomID,\n message.MessageID!,\n reason,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(\n (res.data as { AlreadyReported?: boolean | null })\n .AlreadyReported\n ? t(\"alreadyReported\")\n : t(\"reported\"),\n );\n onClose();\n })\n }\n >\n {t(\"report\")}\n </Button>\n </div>\n <Button\n tone=\"grey\"\n busy={busy === \"ignore\"}\n onClick={() =>\n void run(\"ignore\", async () => {\n const res = await client.chat.ignoreUser(sender);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n toast(t(\"ignored\"));\n onClose();\n })\n }\n >\n {t(\"ignore\")}\n </Button>\n </Popup>\n );\n}\n\nfunction ChannelsPopup({\n defs,\n joined,\n onClose,\n onChanged,\n}: {\n defs: ChatDefs;\n joined: string[];\n onClose: () => void;\n onChanged: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const [ids, setIds] = useState(joined);\n const choices = channelChoices(defs.Channels, ids);\n const toggle = async (id: string, join: boolean) => {\n setBusy(id);\n const res = join\n ? await client.chat.joinChannel(id)\n : await client.chat.leaveChannel(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setIds((prev) => (join ? [...prev, id] : prev.filter((x) => x !== id)));\n onChanged();\n };\n return (\n <Popup title={t(\"channels\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {choices.map((c) => (\n <div\n key={c.id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon glyph=\"chat\" size={24} />\n <div style={{ flex: 1, ...outlined, fontSize: 15 }}>\n {catalog.localize(c.name)}\n </div>\n <Button\n size=\"sm\"\n tone={c.joined ? \"grey\" : \"green\"}\n busy={busy === c.id}\n onClick={() => void toggle(c.id, !c.joined)}\n >\n {c.joined ? t(\"leave\") : t(\"join\")}\n </Button>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
48
+ },
49
+ {
50
+ "path": "i18n.ts",
51
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Chat\",\n direct: \"Private\",\n channels: \"Channels\",\n join: \"Join\",\n leave: \"Leave\",\n noMessages: \"No messages yet — say hi!\",\n noConversations: \"No private chats yet. Start one from a friend's card.\",\n disabled: \"Chat is turned off in this game.\",\n placeholder: \"Message\",\n send: \"Send\",\n wait: \"Wait\",\n sec: \"s\",\n older: \"Earlier messages\",\n muted: \"You are muted until\",\n report: \"Report\",\n reported: \"Thanks — the report was sent.\",\n alreadyReported: \"You already reported this message.\",\n ignore: \"Ignore player\",\n ignored: \"You won't see this player's messages any more.\",\n writePrivately: \"Write privately\",\n reason: \"Reason\",\n rSpam: \"Spam\",\n rAbuse: \"Abuse\",\n rScam: \"Scam\",\n rInappropriate: \"Inappropriate\",\n rOther: \"Other\",\n back: \"Back\",\n system: \"System\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Чат\",\n direct: \"Личные\",\n channels: \"Каналы\",\n join: \"Вступить\",\n leave: \"Выйти\",\n noMessages: \"Сообщений пока нет — поздоровайтесь!\",\n noConversations: \"Личных переписок пока нет. Начните её из карточки друга.\",\n disabled: \"Чат в этой игре выключен.\",\n placeholder: \"Сообщение\",\n send: \"Отправить\",\n wait: \"Подождите\",\n sec: \"с\",\n older: \"Ранние сообщения\",\n muted: \"Вы в муте до\",\n report: \"Пожаловаться\",\n reported: \"Спасибо — жалоба отправлена.\",\n alreadyReported: \"Вы уже жаловались на это сообщение.\",\n ignore: \"Игнорировать игрока\",\n ignored: \"Сообщения этого игрока вы больше не увидите.\",\n writePrivately: \"Написать лично\",\n reason: \"Причина\",\n rSpam: \"Спам\",\n rAbuse: \"Оскорбления\",\n rScam: \"Мошенничество\",\n rInappropriate: \"Неприемлемо\",\n rOther: \"Другое\",\n back: \"Назад\",\n system: \"Система\",\n});\n"
52
+ },
53
+ {
54
+ "path": "index.ts",
55
+ "content": "// @idosgames/mod-chat-ui — Chat for any game: channels as tabs, direct messages with friends, and message actions (report, ignore) in a popup.\n\nexport { chatUiModule } from \"./module\";\n"
56
+ },
57
+ {
58
+ "path": "model.test.ts",
59
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n authorName,\n channelChoices,\n chatBadge,\n isSystem,\n lastCursor,\n mergeMessages,\n orderedChannels,\n sendable,\n slowModeLeft,\n unreadLabel,\n} from \"./model\";\n\nconst msg = (id: string, at: string, extra: Record<string, unknown> = {}) => ({\n MessageID: id,\n SentAt: at,\n SenderUserID: \"U\",\n Text: id,\n ...extra,\n});\n\ndescribe(\"chat\", () => {\n it(\"the seeded chat: a world channel everyone is in, an opt-in trade channel, DMs between friends\", () => {\n const chat = (\n seed as unknown as {\n Chat: {\n Channels: Record<string, never>;\n Direct: { IsEnabled: boolean; WhoCanMessage: string };\n };\n }\n ).Chat;\n expect(channelChoices(chat.Channels, [\"world\"])).toEqual([\n { id: \"world\", name: \"World\", joined: true },\n { id: \"trade\", name: \"Trade\", joined: false },\n ]);\n expect(chat.Direct).toMatchObject({\n IsEnabled: true,\n WhoCanMessage: \"Friends\",\n });\n });\n\n it(\"merges history and polls without duplicates, oldest first\", () => {\n const merged = mergeMessages(\n [msg(\"b\", \"2026-09-28T10:00:02Z\"), msg(\"a\", \"2026-09-28T10:00:01Z\")],\n [msg(\"b\", \"2026-09-28T10:00:02Z\"), msg(\"c\", \"2026-09-28T10:00:03Z\")],\n );\n expect(merged.map((m) => m.MessageID)).toEqual([\"a\", \"b\", \"c\"]);\n expect(lastCursor(\"world\", merged)).toEqual({\n RoomID: \"world\",\n SentAt: \"2026-09-28T10:00:03Z\",\n MessageID: \"c\",\n });\n expect(lastCursor(\"world\", [])).toBeUndefined();\n });\n\n it(\"an uncounted room is not zero, and big counts are capped\", () => {\n expect(unreadLabel(null)).toBe(\"\");\n expect(unreadLabel(0)).toBe(\"\");\n expect(unreadLabel(7)).toBe(\"7\");\n expect(unreadLabel(150)).toBe(\"99+\");\n expect(\n chatBadge([\n { UnreadCount: 2 },\n { UnreadCount: null },\n { UnreadCount: 3 },\n ]),\n ).toBe(5);\n });\n\n it(\"sends trimmed text within the limit and counts down the slow mode\", () => {\n expect(sendable(\" hi \", 200)).toBe(\"hi\");\n expect(sendable(\" \", 200)).toBeNull();\n expect(sendable(\"x\".repeat(201), 200)).toBeNull();\n expect(slowModeLeft(1_000, 3, 2_500)).toBe(2);\n expect(slowModeLeft(1_000, 3, 5_000)).toBe(0);\n expect(slowModeLeft(null, 3, 5_000)).toBe(0);\n });\n\n it(\"orders channels and names authors; system lines have no author to act on\", () => {\n expect(\n orderedChannels([\n { ChannelID: \"b\", SortOrder: 1 },\n { ChannelID: \"a\", SortOrder: 0 },\n ]).map((c) => c.ChannelID),\n ).toEqual([\"a\", \"b\"]);\n expect(\n authorName({\n SenderUserID: \"ABCDEFGHIJKLMN\",\n SenderProfile: { Username: \"Ann\" },\n }),\n ).toBe(\"Ann\");\n expect(authorName({ SenderUserID: \"ABCDEFGHIJKLMN\" })).toBe(\"ABCDE…KLMN\");\n expect(isSystem({ SenderUserID: null })).toBe(true);\n expect(isSystem({ SenderUserID: \"U\", AuthorType: \"Publisher\" })).toBe(true);\n expect(isSystem({ SenderUserID: \"U\", AuthorType: \"Player\" })).toBe(false);\n });\n});\n"
60
+ },
61
+ {
62
+ "path": "model.ts",
63
+ "content": "// Pure helpers of the chat screen (Unity V2/Runtime/UI/Chat): channel order, the feed (merging\n// history and polls without duplicates), read cursors, the slow mode and the badge. The server\n// decides rooms, moderation and who may write — nothing here re-checks it. Covered by model.test.ts.\n\nexport interface MessageView {\n RoomID?: string | null;\n MessageID?: string | null;\n SenderUserID?: string | null;\n SenderProfile?: { Username?: string | null } | null;\n AuthorType?: string | null;\n Text?: string | null;\n SentAt?: string | null;\n}\n\nexport interface ChannelView {\n ChannelID?: string | null;\n RoomID?: string | null;\n DisplayNameKey?: string | null;\n SortOrder?: number | null;\n CanWrite?: boolean | null;\n WriteBlockedReason?: string | null;\n SlowModeSeconds?: number | null;\n MaxLength?: number | null;\n UnreadCount?: number | null;\n}\n\nexport function orderedChannels<T extends ChannelView>(\n channels: readonly T[],\n): T[] {\n return [...channels].sort(\n (a, b) =>\n Number(a.SortOrder ?? 0) - Number(b.SortOrder ?? 0) ||\n String(a.ChannelID).localeCompare(String(b.ChannelID)),\n );\n}\n\n/** Badge text of a room: `null` from the server means \"not counted\", which is not zero. */\nexport function unreadLabel(count: number | null | undefined): string {\n if (count === null || count === undefined) return \"\";\n if (count <= 0) return \"\";\n return count > 99 ? \"99+\" : String(count);\n}\n\nconst FEED_LIMIT = 200;\n\n/** History and polled messages merged: no duplicates, oldest first, the newest FEED_LIMIT kept. */\nexport function mergeMessages(\n existing: readonly MessageView[],\n incoming: readonly MessageView[],\n): MessageView[] {\n const byID = new Map<string, MessageView>();\n for (const m of [...existing, ...incoming])\n if (m.MessageID) byID.set(m.MessageID, m);\n const sorted = [...byID.values()].sort(\n (a, b) =>\n String(a.SentAt ?? \"\").localeCompare(String(b.SentAt ?? \"\")) ||\n String(a.MessageID).localeCompare(String(b.MessageID)),\n );\n return sorted.slice(-FEED_LIMIT);\n}\n\n/** The read cursor up to the newest message drawn (a pair — neighbours share a millisecond). */\nexport function lastCursor(\n roomID: string,\n messages: readonly MessageView[],\n):\n | { RoomID: string; SentAt?: string | null; MessageID?: string | null }\n | undefined {\n const last = messages[messages.length - 1];\n return last\n ? { RoomID: roomID, SentAt: last.SentAt, MessageID: last.MessageID }\n : undefined;\n}\n\nexport function authorName(m: MessageView): string {\n const name = m.SenderProfile?.Username?.trim();\n if (name) return name;\n const id = m.SenderUserID ?? \"\";\n return id.length > 10 ? `${id.slice(0, 5)}…${id.slice(-4)}` : id;\n}\n\n/** A publisher announcement or a system line: no author to report or ignore. */\nexport function isSystem(m: MessageView): boolean {\n return !m.SenderUserID || (m.AuthorType != null && m.AuthorType !== \"Player\");\n}\n\n/** What can be sent: trimmed, not empty, within the room's limit. */\nexport function sendable(\n text: string,\n maxLength: number | null | undefined,\n): string | null {\n const s = text.trim();\n if (!s) return null;\n return maxLength && s.length > maxLength ? null : s;\n}\n\n/** Seconds left of the slow mode after my last message in this room. */\nexport function slowModeLeft(\n lastSentMs: number | null | undefined,\n seconds: number | null | undefined,\n nowMs: number,\n): number {\n if (!lastSentMs || !seconds) return 0;\n return Math.max(0, Math.ceil((lastSentMs + seconds * 1000 - nowMs) / 1000));\n}\n\nexport interface ChannelChoice {\n id: string;\n name: string;\n joined: boolean;\n}\n\n/** Every channel of the title with whether the player is in it — for the join / leave list. */\nexport function channelChoices(\n definitions:\n | Record<\n string,\n {\n Identity?: {\n DisplayNameKey?: string | null;\n SortOrder?: number | null;\n } | null;\n Type?: string | null;\n } | null\n >\n | null\n | undefined,\n joinedIDs: readonly string[],\n): ChannelChoice[] {\n return Object.entries(definitions ?? {})\n .filter(([, d]) => d && d.Type !== \"Announcement\" && d.Type !== \"Group\")\n .sort(\n ([, a], [, b]) =>\n Number(a?.Identity?.SortOrder ?? 0) -\n Number(b?.Identity?.SortOrder ?? 0),\n )\n .map(([id, d]) => ({\n id,\n name: d?.Identity?.DisplayNameKey ?? id,\n joined: joinedIDs.includes(id),\n }));\n}\n\n/** Badge: counted unread messages across the rooms (uncounted rooms add nothing). */\nexport function chatBadge(\n rooms: ReadonlyArray<{ UnreadCount?: number | null }>,\n): number {\n return rooms.reduce((n, r) => n + Math.max(0, Number(r.UnreadCount ?? 0)), 0);\n}\n"
64
+ },
65
+ {
66
+ "path": "module.ts",
67
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeChatScreen, makeChatWatcher } from \"./ChatScreen\";\nimport { t } from \"./i18n\";\n\n// Chat as a feature of the game. The watcher reads the unread count once per session — a closed\n// chat polls nothing (every request is billed to the publisher). features.open(\"chat\", { userID })\n// opens a private chat with that player.\nexport const chatUiModule: Module = defineModule({\n id: \"chat-ui\",\n meta: { name: \"Chat\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"chat\",\n label: t(\"title\"),\n icon: \"chat\",\n group: \"social\",\n order: 85,\n Screen: makeChatScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeChatWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"chat\" }),\n describeActions: {},\n });\n },\n});\n"
68
+ },
69
+ {
70
+ "path": "module.meta.json",
71
+ "content": "{\n \"id\": \"chat-ui\",\n \"type\": \"feature\",\n \"summary\": \"Chat for any game: channels as tabs, private chats, a feed with earlier messages, slow mode, and report / ignore / write privately in a popup.\",\n \"description\": \"A ready player chat (Unity V2/Runtime/UI/Chat) for ANY game, built on the @idosgames/react/ui kit and client.chat. The title's channels are tabs (names are localization keys) plus 'Private' — 1-on-1 chats (with friends when the title says so). The feed loads its history, pages back to earlier messages, shows the server's copy of what was sent (moderation may mask a word) and counts down the slow mode; a channel the player may not write to shows the server's reason. ONE poll covers every room and runs only while the screen is open, at the interval the server sets; rooms are marked read when the player leaves them. Popups: a message's actions (write privately, report with a reason, ignore the player) and the channel list to join or leave opt-in channels. Registers the 'chat' feature; features.open('chat', { userID }) opens a private chat (the friends card uses it). Its badge is the unread count, read once per session — a closed chat costs nothing. Ships title-seed.json: a world channel, an opt-in trade channel, private chats between friends.\",\n \"provides\": [\n \"chat channels as tabs\",\n \"private chats\",\n \"message feed with earlier messages\",\n \"slow mode countdown\",\n \"report, ignore and write privately\",\n \"join and leave channels\",\n \"chat badge\",\n \"demo channels\"\n ],\n \"tags\": [\"chat\", \"social\", \"messages\", \"dm\", \"moderation\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
72
+ }
73
+ ]
74
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "id": "craft-ui",
3
+ "meta": {
4
+ "name": "Craft",
5
+ "type": "app",
6
+ "engine": "dom"
7
+ },
8
+ "catalog": {
9
+ "type": "feature",
10
+ "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.",
11
+ "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.",
12
+ "provides": [
13
+ "craft screen from the title's recipes",
14
+ "trade-up by rarity or collection",
15
+ "contract popup with owned items",
16
+ "auto-fill of the contract",
17
+ "price options",
18
+ "craft result with reward animation",
19
+ "craft badge for runnable recipes",
20
+ "demo recipes"
21
+ ],
22
+ "tags": [
23
+ "craft",
24
+ "crafting",
25
+ "trade-up",
26
+ "items",
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.5.0",
40
+ "@idosgames/react": "0.3.0",
41
+ "react": "19.2.7"
42
+ },
43
+ "contentHash": "258ad0cd09a83c498f719c1da59e77654eed3bd1ff42c23d49088c457bedc5de",
44
+ "files": [
45
+ {
46
+ "path": "CraftScreen.tsx",
47
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n SectionTitle,\n Tabs,\n canAfford,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n addToContract,\n autoFill,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type CraftSection,\n type Recipe,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Craft (Unity Alikhan/Craft: recipe cards → contract panel → info popup). The screen is the recipe\n// ladder — \"3 × Common → 1 × Rare\"; a recipe opens its CONTRACT as a popup: the slots to fill, the\n// player's fitting items to tap in (or fill automatically), the ways to pay, what can come out, and\n// \"Craft\". The server rolls the result; it pops out with the reward animation.\n\nexport function makeCraftScreen(features: FeatureRegistry) {\n return function CraftScreen(_: FeatureScreenProps): ReactNode {\n return <Craft features={features} />;\n };\n}\n\nfunction Craft({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const layout = useGameLayout();\n const [, loaded] = useState(0);\n const section = configSection<CraftSection>(client, \"Craft\");\n useEffect(() => {\n if (!section)\n void client.craft.getDefinitions().then(() => loaded((n) => n + 1));\n void client.user.getUserInventory();\n }, [client, section]);\n const list = recipes(section);\n const types = [...new Set(list.map((r) => r.type))];\n const [tab, setTab] = useState<Recipe[\"type\"]>(\"rarity\");\n const [open, setOpen] = useState<Recipe | null>(null);\n const state = useUserState();\n const owned = ownedCounts(state?.InventoryV2 as never);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string }\n >;\n\n if (list.length === 0)\n return <EmptyState glyph=\"hammer\" text={t(\"noRecipes\")} />;\n const current = types.includes(tab) ? tab : types[0]!;\n const shown = list.filter((r) => r.type === current);\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs\n tabs={types.map((x) => ({\n id: x,\n label: x === \"rarity\" ? t(\"byRarity\") : t(\"byCollection\"),\n }))}\n value={current}\n onChange={setTab}\n />\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns:\n layout === \"phone\"\n ? \"1fr\"\n : \"repeat(auto-fill, minmax(320px, 1fr))\",\n gap: 12,\n }}\n >\n {shown.map((r, i) => {\n const fit = eligibleInputs(r, items, owned).reduce(\n (s, e) => s + e.available,\n 0,\n );\n const ready = fit >= r.count;\n return (\n <Card\n key={r.id}\n highlight={ready}\n onClick={() => setOpen(r)}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto 1fr\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <RarityChip rarity={r.input} label={`${r.count} × ${r.input}`} />\n <div className={ready ? \"idos-bounce\" : undefined}>\n <Icon glyph=\"hammer\" size={34} />\n </div>\n <RarityChip rarity={r.output} label={`1 × ${r.output}`} />\n <div\n style={{\n gridColumn: \"1 / -1\",\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"need\")}: {Math.min(fit, r.count)} / {r.count}\n </span>\n <ResourceList lines={r.options[0]?.price ?? []} size={16} />\n </div>\n </Card>\n );\n })}\n </div>\n {open ? (\n <ContractPopup\n recipe={open}\n owned={owned}\n items={items}\n onShop={\n features.get(\"store\") ? () => features.open(\"store\") : undefined\n }\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction RarityChip({\n rarity,\n label,\n}: {\n rarity: string;\n label: string;\n}): ReactNode {\n const catalog = useCatalog();\n const color = catalog.rarityColor(rarity);\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div\n style={{\n width: 58,\n height: 58,\n borderRadius: 14,\n border: `3px solid ${color}`,\n background: `radial-gradient(circle, ${color}55, ${v.panelDeep} 70%)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <Icon glyph=\"box\" size={34} />\n </div>\n <span style={{ ...outlined, fontSize: 12, color }}>{label}</span>\n </div>\n );\n}\n\nfunction ContractPopup({\n recipe,\n owned,\n items,\n onShop,\n onClose,\n}: {\n recipe: Recipe;\n owned: Record<string, number>;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n onShop?: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const anvil = useRef<HTMLDivElement>(null);\n const eligible = eligibleInputs(recipe, items, owned);\n const [contract, setContract] = useState<string[]>([]);\n const [optionID, setOptionID] = useState(recipe.options[0]?.id ?? \"\");\n const [busy, setBusy] = useState(false);\n const option = recipe.options.find((o) => o.id === optionID);\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const affordable = canAfford(option?.price ?? [], balances);\n\n const craft = async () => {\n setBusy(true);\n const started = Date.now();\n const res = await client.craft.craft(recipe.id, contract, 1, option?.id);\n await new Promise((r) =>\n setTimeout(\n r,\n Math.max(0, (kit.motion.on ? 600 : 0) - (Date.now() - started)),\n ),\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.fx.burst(anvil.current);\n setContract([]);\n celebrate(grantedBy(res.data), { title: t(\"crafted\"), sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={`${t(\"contract\")}: ${recipe.count} × ${recipe.input} → ${recipe.output}`}\n onClose={onClose}\n width={520}\n >\n <div\n ref={anvil}\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n animation: busy ? \"idos-shake .35s ease-in-out infinite\" : undefined,\n }}\n >\n {Array.from({ length: recipe.count }, (_, i) => {\n const itemID = contract[i];\n return itemID ? (\n <ItemTile\n key={i}\n itemID={itemID}\n size={56}\n onClick={() => setContract((c) => c.filter((_, j) => j !== i))}\n />\n ) : (\n <div\n key={i}\n style={{\n width: 56,\n height: 56,\n borderRadius: 14,\n border: `3px dashed ${v.panelEdge}`,\n background: \"rgba(0,0,0,.25)\",\n }}\n />\n );\n })}\n </div>\n <SectionTitle\n right={\n <span style={{ display: \"flex\", gap: 6 }}>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() => setContract(autoFill(recipe, eligible))}\n disabled={eligible.length === 0}\n >\n {t(\"auto\")}\n </Button>\n {contract.length > 0 ? (\n <Button size=\"sm\" tone=\"grey\" onClick={() => setContract([])}>\n {t(\"clear\")}\n </Button>\n ) : null}\n </span>\n }\n >\n <span style={{ fontSize: 15 }}>{t(\"yourItems\")}</span>\n </SectionTitle>\n {eligible.length === 0 ? (\n <EmptyState\n glyph=\"box\"\n text={t(\"noItems\")}\n action={\n onShop ? <Button onClick={onShop}>{t(\"toShop\")}</Button> : undefined\n }\n />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n {eligible.map((e) => {\n const used = contract.filter((x) => x === e.itemID).length;\n return (\n <ItemTile\n key={e.itemID}\n itemID={e.itemID}\n count={e.available - used}\n size={64}\n dim={used >= e.available}\n onClick={() =>\n setContract((c) =>\n addToContract(c, e.itemID, recipe, e.available),\n )\n }\n />\n );\n })}\n </div>\n )}\n <Outputs recipe={recipe} items={items} />\n {recipe.options.length > 1 ? (\n <Tabs\n tabs={recipe.options.map((o) => ({\n id: o.id,\n label: o.name ?? o.id,\n }))}\n value={optionID}\n onChange={setOptionID}\n />\n ) : null}\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={contract.length === recipe.count && affordable}\n disabled={contract.length < recipe.count || !affordable}\n busy={busy}\n onClick={() => void craft()}\n data-tutorial-anchor=\"craft:go\"\n >\n <Icon glyph=\"hammer\" size={22} /> {t(\"craft\")}{\" \"}\n {option && option.price.length > 0 ? (\n <ResourceList\n lines={option.price}\n size={18}\n dimIf={() => !affordable}\n />\n ) : (\n t(\"free\")\n )}\n </Button>\n </Popup>\n );\n}\n\nfunction Outputs({\n recipe,\n items,\n}: {\n recipe: Recipe;\n items: Map<string, ItemDefinitionView & { CatalogID?: string }>;\n}): ReactNode {\n const outputs = possibleOutputs(recipe, items);\n if (outputs.length === 0) return null;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"canYield\")}\n </div>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 6,\n justifyContent: \"center\",\n }}\n >\n {outputs.map((id) => (\n <ItemTile key={id} itemID={id} size={48} />\n ))}\n </div>\n </div>\n );\n}\n"
48
+ },
49
+ {
50
+ "path": "i18n.ts",
51
+ "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"
52
+ },
53
+ {
54
+ "path": "index.ts",
55
+ "content": "// @idosgames/mod-craft-ui — 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 { craftUiModule } from \"./module\";\n"
56
+ },
57
+ {
58
+ "path": "model.test.ts",
59
+ "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n addToContract,\n autoFill,\n craftableRecipes,\n eligibleInputs,\n ownedCounts,\n possibleOutputs,\n recipes,\n type CraftSection,\n} from \"./model\";\n\n// The demo items live in the showcase's shared Item section; a module does not import another\n// module's files, so a small copy of their shape is inlined here.\nconst items = new Map(\n Object.entries({\n health_potion: {\n Metadata: { RarityID: \"Common\" },\n CatalogID: \"Item\",\n Weight: 1,\n },\n mana_potion: {\n Metadata: { RarityID: \"Common\" },\n CatalogID: \"Item\",\n Weight: 1,\n },\n xp_scroll: { Metadata: { RarityID: \"Rare\" }, CatalogID: \"Item\", Weight: 1 },\n iron_sword: {\n Metadata: { RarityID: \"Rare\" },\n CatalogID: \"Item\",\n Weight: 1,\n },\n hidden_rare: {\n Metadata: { RarityID: \"Rare\" },\n CatalogID: \"Item\",\n Weight: 0,\n },\n golden_crown: {\n Metadata: { RarityID: \"Legendary\" },\n CatalogID: \"Item\",\n Weight: 1,\n },\n }),\n);\nconst list = recipes((seed as unknown as { Craft: CraftSection }).Craft);\n\ndescribe(\"craft\", () => {\n it(\"reads the seeded recipes bottom-up with their prices\", () => {\n expect(list.map((r) => r.id)).toEqual([\n \"common_to_rare\",\n \"rare_to_legendary\",\n ]);\n expect(list[0]).toMatchObject({\n type: \"rarity\",\n input: \"Common\",\n output: \"Rare\",\n count: 3,\n });\n expect(list[0]!.options[0]!.price).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 50 },\n ]);\n });\n\n it(\"offers only items of the input rarity the player owns, most plentiful first\", () => {\n const owned = { health_potion: 2, mana_potion: 5, xp_scroll: 4 };\n expect(eligibleInputs(list[0]!, items, owned)).toEqual([\n { itemID: \"mana_potion\", available: 5 },\n { itemID: \"health_potion\", available: 2 },\n ]);\n });\n\n it(\"never promises an output the engine will not roll (Weight 0)\", () => {\n expect(possibleOutputs(list[0]!, items)).toEqual([\n \"iron_sword\",\n \"xp_scroll\",\n ]);\n });\n\n it(\"auto-fills up to the required count and refuses copies the player does not have\", () => {\n const eligible = [\n { itemID: \"mana_potion\", available: 2 },\n { itemID: \"health_potion\", available: 5 },\n ];\n expect(autoFill(list[0]!, eligible)).toEqual([\n \"mana_potion\",\n \"mana_potion\",\n \"health_potion\",\n ]);\n let c = addToContract([], \"mana_potion\", list[0]!, 1);\n expect(addToContract(c, \"mana_potion\", list[0]!, 1)).toBe(c);\n c = addToContract(\n addToContract(c, \"health_potion\", list[0]!, 3),\n \"health_potion\",\n list[0]!,\n 3,\n );\n expect(addToContract(c, \"health_potion\", list[0]!, 3)).toBe(c);\n });\n\n it(\"does not count worn equipment as craft material\", () => {\n const owned = ownedCounts({\n Items: {\n iron_sword: { TotalAmount: 2 },\n health_potion: { StackableAmount: 3 },\n },\n UnstackableItems: {\n a: { ItemID: \"iron_sword\", EquippedSlot: \"Weapon\" },\n b: { ItemID: \"iron_sword\" },\n },\n });\n expect(owned).toEqual({ iron_sword: 1, health_potion: 3 });\n });\n\n it(\"the badge counts recipes the player can run now\", () => {\n expect(craftableRecipes(list, items, { health_potion: 3 })).toBe(1);\n expect(craftableRecipes(list, items, { health_potion: 2 })).toBe(0);\n const noOutputs = new Map(\n [...items].filter(([id]) => id !== \"golden_crown\"),\n );\n expect(craftableRecipes(list, noOutputs, { xp_scroll: 3 })).toBe(0);\n });\n});\n"
60
+ },
61
+ {
62
+ "path": "model.ts",
63
+ "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"
64
+ },
65
+ {
66
+ "path": "module.ts",
67
+ "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 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 craftUiModule: Module = defineModule({\n id: \"craft-ui\",\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 Screen: makeCraftScreen(ctx.features),\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"
68
+ },
69
+ {
70
+ "path": "module.meta.json",
71
+ "content": "{\n \"id\": \"craft-ui\",\n \"type\": \"feature\",\n \"summary\": \"Craft screen for any game: trade-up recipes by rarity or collection, a contract of owned items, auto-fill, price options and the result popup.\",\n \"description\": \"A ready craft screen (Unity Alikhan/Craft) for ANY game, built on the @idosgames/react/ui kit and client.craft. The screen lists the title's recipes as cards, lowest rarity first: what goes in (N items of a rarity or a collection) and what can come out. A recipe opens as a popup — the contract: the fitting items the player owns (worn equipment does not count), tap to add, 'Auto' fills from the most plentiful, the price options, and 'Craft'. The server rolls the result (only items with a roll Weight > 0 are promised) and the reward popup shows it flying into the inventory. 'Not enough coins' offers to open the shop (ctx.features.open('store')). Registers the 'craft' feature; its badge counts recipes the player can run now. Ships title-seed.json: 3 Common → Rare for coins and 3 Rare → Legendary for gems.\",\n \"provides\": [\n \"craft screen from the title's recipes\",\n \"trade-up by rarity or collection\",\n \"contract popup with owned items\",\n \"auto-fill of the contract\",\n \"price options\",\n \"craft result with reward animation\",\n \"craft badge for runnable recipes\",\n \"demo recipes\"\n ],\n \"tags\": [\"craft\", \"crafting\", \"trade-up\", \"items\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
72
+ }
73
+ ]
74
+ }