@idosgames/mcp 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +86 -53
- package/registry/modules/ai.json +1 -1
- package/registry/modules/board-game.json +1 -1
- package/registry/modules/boosts.json +1 -1
- package/registry/modules/character.json +32 -10
- package/registry/modules/chat.json +1 -1
- package/registry/modules/collections.json +1 -1
- package/registry/modules/coop-events.json +1 -1
- package/registry/modules/craft.json +1 -1
- package/registry/modules/deals.json +1 -1
- package/registry/modules/events.json +1 -1
- package/registry/modules/friends.json +1 -1
- package/registry/modules/idle-rpg.json +91 -52
- package/registry/modules/inventory.json +1 -1
- package/registry/modules/invite.json +1 -1
- package/registry/modules/leaderboard.json +1 -1
- package/registry/modules/lootboxes.json +1 -1
- package/registry/modules/mailbox.json +1 -1
- package/registry/modules/marketplace.json +1 -1
- package/registry/modules/mini-game.json +1 -1
- package/registry/modules/multiplayer.json +1 -1
- package/registry/modules/premium.json +1 -1
- package/registry/modules/profile.json +1 -1
- package/registry/modules/pvp.json +1 -1
- package/registry/modules/quests.json +4 -4
- package/registry/modules/rewards.json +1 -1
- package/registry/modules/season.json +1 -1
- package/registry/modules/store.json +1 -1
- package/registry/modules/tutorials.json +1 -1
- package/registry/modules/workshop.json +1 -1
|
@@ -8,17 +8,20 @@
|
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
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.",
|
|
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, 'Take off all', 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, 'Upgrade all' — as many levels as the balances cover, cheapest first, in one batch —, 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. Emits character:hero-selected@1 with the hero the player plays with (on start-up and on every pick) so a game can field that hero.",
|
|
12
12
|
"provides": [
|
|
13
13
|
"hero roster with unlock for currency",
|
|
14
14
|
"rank up with price by the rank ladder",
|
|
15
15
|
"stat upgrades with costs, caps and requirements",
|
|
16
|
+
"upgrade all stats in one batch",
|
|
16
17
|
"stat breakdown popup by source",
|
|
17
18
|
"equipment slots and equip / take off",
|
|
18
19
|
"equip best gear automatically",
|
|
20
|
+
"take off all gear",
|
|
19
21
|
"item level upgrades",
|
|
20
22
|
"character skins",
|
|
21
|
-
"demo heroes"
|
|
23
|
+
"demo heroes",
|
|
24
|
+
"selected hero for the game (event)"
|
|
22
25
|
],
|
|
23
26
|
"tags": [
|
|
24
27
|
"heroes",
|
|
@@ -36,23 +39,38 @@
|
|
|
36
39
|
"name": "iDos Games",
|
|
37
40
|
"url": "https://idosgames.com"
|
|
38
41
|
},
|
|
39
|
-
"version": "0.1.
|
|
42
|
+
"version": "0.1.2",
|
|
43
|
+
"events": {
|
|
44
|
+
"emits": [
|
|
45
|
+
{
|
|
46
|
+
"topic": "character:hero-selected@1",
|
|
47
|
+
"when": "on start-up (the saved or first playable hero, once the roster is known) and whenever the player selects another hero on the Heroes screen",
|
|
48
|
+
"payload": {
|
|
49
|
+
"characterId": "string"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
40
54
|
},
|
|
41
55
|
"dependencies": {
|
|
42
56
|
"@idosgames/core": "0.21.0",
|
|
43
57
|
"@idosgames/module-sdk": "0.8.2",
|
|
44
|
-
"@idosgames/react": "0.4.
|
|
58
|
+
"@idosgames/react": "0.4.4",
|
|
45
59
|
"react": "19.2.7"
|
|
46
60
|
},
|
|
47
|
-
"contentHash": "
|
|
61
|
+
"contentHash": "1b19f6ab0b4e59098017ce9173984fb62e5fe2d296aeb37849876755df170582",
|
|
48
62
|
"files": [
|
|
49
63
|
{
|
|
50
64
|
"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 // Heroes and gear (with their instances) are in the login state and every reply keeps them\n // fresh. Opening the screen re-checks them against changes made elsewhere (another device) —\n // through the central cache: nothing at all while they were confirmed a minute ago.\n void client.cache.ensureState([\"Character\", \"InventoryV2\"], {\n maxAgeMs: 60_000,\n });\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: v.wellSoft,\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: v.wellSoft,\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"
|
|
65
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport type {\r\n FeatureRegistry,\r\n FeatureScreenProps,\r\n} from \"@idosgames/module-sdk\";\r\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\r\nimport {\r\n Badge,\r\n Button,\r\n Card,\r\n EmptyState,\r\n Icon,\r\n ItemTile,\r\n Popup,\r\n ProgressBar,\r\n ResourceList,\r\n SectionTitle,\r\n Tabs,\r\n canAfford,\r\n configSection,\r\n costLines,\r\n errorText,\r\n formatAmount,\r\n outlined,\r\n panel,\r\n useCatalog,\r\n useGameLayout,\r\n usePopupClose,\r\n useSound,\r\n useStagger,\r\n useToast,\r\n useUiKit,\r\n v,\r\n type ResourceLine,\r\n} from \"@idosgames/react/ui\";\r\nimport {\r\n SKIN_KEY,\r\n bestLoadout,\r\n breakdown,\r\n fitProblem,\r\n gearPieces,\r\n itemLevelCost,\r\n pieceBonuses,\r\n rankCost,\r\n resolveLadder,\r\n resolveSlots,\r\n resolveStats,\r\n roster,\r\n statCost,\r\n statMaxLevel,\r\n statNumber,\r\n unequipAllSlots,\r\n unmetRequirements,\r\n upgradeAllPlan,\r\n type CharacterModelView,\r\n type CharacterSection,\r\n type GearPiece,\r\n type InstanceView,\r\n type ItemDefWithGear,\r\n type RosterEntry,\r\n type SlotView,\r\n type StatView,\r\n} from \"./model\";\r\nimport { t } from \"./i18n\";\r\nimport { readSelected, resolveSelected, saveSelected } from \"./selection\";\r\n\r\n// Heroes (Unity: the Hero button → CharacterListPanel, CharacterDetailPanel, EquipmentPanel). The\r\n// screen is the equipment hub of the selected hero — portrait, rank and Power, the slots, \"Equip\r\n// best\", and the gear the player owns. Everything with more data opens as a popup: the hero list\r\n// (select / unlock), the stats (upgrade, and \"where does this number come from\"), a piece of gear\r\n// (equip / take off / level up), a slot's fitting gear, and skins.\r\n\r\n/**\r\n * `onSelect` — the player picked a hero to play with; the module turns it into the\r\n * `character:hero-selected@1` event for the game.\r\n */\r\nexport function makeCharacterScreen(\r\n features: FeatureRegistry,\r\n onSelect: (characterId: string) => void,\r\n) {\r\n return function CharacterScreen({ args }: FeatureScreenProps): ReactNode {\r\n return (\r\n <Heroes\r\n features={features}\r\n onSelect={onSelect}\r\n openInstance={\r\n typeof args?.itemInstanceID === \"string\" ? args.itemInstanceID : null\r\n }\r\n />\r\n );\r\n };\r\n}\r\n\r\ntype PopupState =\r\n | { kind: \"heroes\" }\r\n | { kind: \"stats\" }\r\n | { kind: \"skins\" }\r\n | { kind: \"slot\"; slot: SlotView }\r\n | { kind: \"piece\"; instanceID: string }\r\n | null;\r\n\r\nfunction useData() {\r\n const client = useIDosGamesClient();\r\n const state = useUserState();\r\n const catalog = useCatalog();\r\n // Definitions arrive with the title config; a title whose config did not carry them fetches them\r\n // once — and the screen redraws when they land (the section cache emits no user-state event).\r\n const [, loaded] = useState(0);\r\n const section = configSection<CharacterSection>(client, \"Character\");\r\n useEffect(() => {\r\n if (!section)\r\n void client.character\r\n .getCharacterDefinitions()\r\n .then(() => loaded((n) => n + 1));\r\n // Heroes and gear (with their instances) are in the login state and every reply keeps them\r\n // fresh. Opening the screen re-checks them against changes made elsewhere (another device) —\r\n // through the central cache: nothing at all while they were confirmed a minute ago.\r\n void client.cache.ensureState([\"Character\", \"InventoryV2\"], {\r\n maxAgeMs: 60_000,\r\n });\r\n }, [client, section]);\r\n const owned = (state?.Character?.Characters ?? {}) as Record<\r\n string,\r\n CharacterModelView\r\n >;\r\n const inventory = state?.InventoryV2 as\r\n | {\r\n UnstackableItems?: Record<string, InstanceView | null>;\r\n VirtualCurrencies?: Record<string, { Amount?: number }>;\r\n }\r\n | undefined;\r\n const items = catalog.items as Map<string, ItemDefWithGear>;\r\n // Not memoized: the SDK cache updates UnstackableItems IN PLACE (an equip flips EquippedSlot on\r\n // the same object), so a memo keyed by the object would keep showing the old state.\r\n const pieces = gearPieces(inventory?.UnstackableItems, items);\r\n return {\r\n client,\r\n section,\r\n owned,\r\n inventory,\r\n items,\r\n pieces,\r\n balances: inventory?.VirtualCurrencies,\r\n };\r\n}\r\n\r\nfunction Heroes({\r\n features,\r\n onSelect,\r\n openInstance,\r\n}: {\r\n features: FeatureRegistry;\r\n onSelect: (characterId: string) => void;\r\n openInstance: string | null;\r\n}): ReactNode {\r\n const { client, section, owned, items, pieces, balances } = useData();\r\n const catalog = useCatalog();\r\n const layout = useGameLayout();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const cardRef = useRef<HTMLDivElement>(null);\r\n const [popup, setPopup] = useState<PopupState>(\r\n openInstance ? { kind: \"piece\", instanceID: openInstance } : null,\r\n );\r\n const [busy, setBusy] = useState<string | null>(null);\r\n const [selectedID, setSelectedID] = useState<string | null>(readSelected);\r\n\r\n const list = roster(section, owned);\r\n if (list.length === 0)\r\n return <EmptyState glyph=\"hero\" text={t(\"noHeroes\")} />;\r\n const hero = resolveSelected(list, selectedID)!;\r\n const select = (id: string) => {\r\n setSelectedID(id);\r\n saveSelected(id);\r\n onSelect(id);\r\n };\r\n\r\n const ladder = resolveLadder(section, hero.def);\r\n const nextRank = rankCost(ladder, hero.rank);\r\n const slots = resolveSlots(section, hero.def);\r\n const changes =\r\n hero.state === \"locked\"\r\n ? []\r\n : bestLoadout(\r\n hero.id,\r\n hero.rank,\r\n slots,\r\n pieces,\r\n hero.model?.Equipment,\r\n items,\r\n );\r\n const rarity = catalog.rarityColor(hero.def.Classification?.RarityID);\r\n const heroName = catalog.localize(hero.def.Identity?.DisplayName ?? hero.id);\r\n const skinCount = Object.keys(hero.def.Skins?.Definitions ?? {}).length;\r\n\r\n const rankUp = async () => {\r\n setBusy(\"rank\");\r\n const res = await client.character.upgradeCharacterLevel(hero.id);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"levelUp\");\r\n kit.fx.burst(cardRef.current);\r\n };\r\n\r\n const equipBest = async () => {\r\n setBusy(\"best\");\r\n const res = await client.character.equipItems(hero.id, changes);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"claim\");\r\n kit.fx.burst(cardRef.current);\r\n };\r\n\r\n // Always enabled (Unity EquipmentPanel.OnUnequipAllClicked): it is the way out of a local/server\r\n // mismatch, and a mismatch looks exactly like \"nothing is worn here\". The server empties only what\r\n // it has; the hero and the gear are re-read afterwards either way — the player pressed it because\r\n // what they see is not what they expect.\r\n const unequipAll = async () => {\r\n setBusy(\"off\");\r\n const slotIDs = unequipAllSlots(\r\n hero.id,\r\n slots,\r\n hero.model?.Equipment,\r\n pieces,\r\n );\r\n const res =\r\n slotIDs.length > 0\r\n ? await client.character.unequipItems(hero.id, slotIDs)\r\n : null;\r\n await client.cache.ensureState([\"Character\", \"InventoryV2\"], {\r\n force: true,\r\n });\r\n setBusy(null);\r\n if (res && !res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"click\");\r\n };\r\n\r\n const card = (\r\n <div\r\n ref={cardRef}\r\n className=\"idos-shine\"\r\n style={{\r\n ...panel,\r\n padding: 16,\r\n display: \"grid\",\r\n gap: 10,\r\n justifyItems: \"center\",\r\n background: `radial-gradient(circle at 50% 30%, ${rarity}55 0%, ${v.panelDeep} 75%)`,\r\n border: `2px solid ${rarity}`,\r\n }}\r\n >\r\n <div\r\n className=\"idos-bounce\"\r\n style={{\r\n width: 110,\r\n height: 110,\r\n borderRadius: 28,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n background: v.wellSoft,\r\n border: `3px solid ${rarity}`,\r\n }}\r\n >\r\n <Icon\r\n glyph={\r\n hero.def.Identity?.AssetPaths?.portrait ??\r\n hero.def.Identity?.AssetPaths?.icon ??\r\n \"hero\"\r\n }\r\n size={80}\r\n />\r\n </div>\r\n <div style={{ ...outlined, fontSize: 22 }}>{heroName}</div>\r\n {hero.def.Classification?.ClassID ? (\r\n <div style={{ ...outlined, fontSize: 12, color: rarity }}>\r\n {hero.def.Classification.ClassID} · {hero.def.Classification.RarityID}\r\n </div>\r\n ) : null}\r\n <div style={{ display: \"flex\", gap: 18 }}>\r\n <Stat\r\n label={t(\"rank\")}\r\n value={`${hero.rank}${ladder?.MaxRank ? ` / ${ladder.MaxRank}` : \"\"}`}\r\n />\r\n <Stat label={t(\"power\")} value={formatAmount(hero.power)} />\r\n </div>\r\n {hero.state === \"locked\" ? null : nextRank === null ? (\r\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\r\n {t(\"maxRank\")}\r\n </div>\r\n ) : (\r\n <Button\r\n tone=\"gold\"\r\n attract={canAfford(nextRank, balances)}\r\n disabled={!canAfford(nextRank, balances)}\r\n busy={busy === \"rank\"}\r\n onClick={() => void rankUp()}\r\n data-tutorial-anchor=\"heroes:rank-up\"\r\n >\r\n {t(\"rankUp\")}{\" \"}\r\n {nextRank.length ? (\r\n <ResourceList\r\n lines={nextRank}\r\n size={18}\r\n dimIf={() => !canAfford(nextRank, balances)}\r\n />\r\n ) : (\r\n t(\"free\")\r\n )}\r\n </Button>\r\n )}\r\n <div\r\n style={{\r\n display: \"flex\",\r\n gap: 8,\r\n flexWrap: \"wrap\",\r\n justifyContent: \"center\",\r\n }}\r\n >\r\n <span style={{ position: \"relative\" }}>\r\n <Button\r\n tone=\"blue\"\r\n size=\"sm\"\r\n onClick={() => setPopup({ kind: \"heroes\" })}\r\n >\r\n <Icon glyph=\"friends\" size={18} /> {t(\"heroes\")}\r\n </Button>\r\n <Badge\r\n count={\r\n list.filter(\r\n (h) =>\r\n h.state === \"locked\" &&\r\n h.unlockPrice.length > 0 &&\r\n canAfford(h.unlockPrice, balances),\r\n ).length\r\n }\r\n dot\r\n />\r\n </span>\r\n {hero.state !== \"locked\" ? (\r\n <Button\r\n tone=\"blue\"\r\n size=\"sm\"\r\n onClick={() => setPopup({ kind: \"stats\" })}\r\n >\r\n <Icon glyph=\"sword\" size={18} /> {t(\"stats\")}\r\n </Button>\r\n ) : null}\r\n {skinCount > 0 && hero.state !== \"locked\" ? (\r\n <Button\r\n tone=\"blue\"\r\n size=\"sm\"\r\n onClick={() => setPopup({ kind: \"skins\" })}\r\n >\r\n <Icon glyph=\"user\" size={18} /> {t(\"skins\")}\r\n </Button>\r\n ) : null}\r\n </div>\r\n </div>\r\n );\r\n\r\n const equipment = (\r\n <div style={{ display: \"grid\", gap: 14 }}>\r\n <SectionTitle\r\n right={\r\n hero.state === \"locked\" ? null : (\r\n <span style={{ display: \"flex\", gap: 6 }}>\r\n <Button\r\n tone=\"blue\"\r\n size=\"sm\"\r\n busy={busy === \"off\"}\r\n onClick={() => void unequipAll()}\r\n >\r\n {t(\"unequipAll\")}\r\n </Button>\r\n {changes.length > 0 ? (\r\n <Button\r\n tone=\"green\"\r\n size=\"sm\"\r\n attract\r\n busy={busy === \"best\"}\r\n onClick={() => void equipBest()}\r\n data-tutorial-anchor=\"heroes:equip-best\"\r\n >\r\n {t(\"equipBest\")}\r\n </Button>\r\n ) : null}\r\n </span>\r\n )\r\n }\r\n >\r\n {t(\"equipment\")}\r\n </SectionTitle>\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 88 : 104}px, 1fr))`,\r\n gap: 12,\r\n justifyItems: \"center\",\r\n }}\r\n >\r\n {slots.map((slot) => (\r\n <SlotTile\r\n key={slot.id}\r\n slot={slot}\r\n hero={hero}\r\n pieces={pieces}\r\n onClick={() => setPopup({ kind: \"slot\", slot })}\r\n />\r\n ))}\r\n </div>\r\n <Bag\r\n pieces={pieces.filter((p) => !p.equippedBy)}\r\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\r\n onShop={\r\n features.get(\"store\") ? () => features.open(\"store\") : undefined\r\n }\r\n />\r\n </div>\r\n );\r\n\r\n return (\r\n <div style={{ display: \"grid\", gap: 18 }}>\r\n {layout === \"desktop\" ? (\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gap: 20,\r\n gridTemplateColumns: \"minmax(0, 4fr) minmax(0, 6fr)\",\r\n alignItems: \"start\",\r\n }}\r\n >\r\n {card}\r\n {equipment}\r\n </div>\r\n ) : (\r\n <>\r\n {card}\r\n {equipment}\r\n </>\r\n )}\r\n {popup?.kind === \"heroes\" ? (\r\n <HeroesPopup\r\n list={list}\r\n current={hero.id}\r\n balances={balances}\r\n onSelect={select}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n {popup?.kind === \"stats\" ? (\r\n <StatsPopup\r\n hero={hero}\r\n section={section}\r\n pieces={pieces}\r\n items={items}\r\n balances={balances}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n {popup?.kind === \"skins\" ? (\r\n <SkinsPopup hero={hero} onClose={() => setPopup(null)} />\r\n ) : null}\r\n {popup?.kind === \"slot\" ? (\r\n <SlotPopup\r\n slot={popup.slot}\r\n hero={hero}\r\n pieces={pieces}\r\n items={items}\r\n onPick={(id) => setPopup({ kind: \"piece\", instanceID: id })}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n {popup?.kind === \"piece\" ? (\r\n <PiecePopup\r\n instanceID={popup.instanceID}\r\n hero={hero}\r\n slots={slots}\r\n pieces={pieces}\r\n items={items}\r\n balances={balances}\r\n onClose={() => setPopup(null)}\r\n />\r\n ) : null}\r\n </div>\r\n );\r\n}\r\n\r\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\r\n return (\r\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\r\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {label}\r\n </span>\r\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\r\n </div>\r\n );\r\n}\r\n\r\nfunction SlotTile({\r\n slot,\r\n hero,\r\n pieces,\r\n onClick,\r\n}: {\r\n slot: SlotView;\r\n hero: RosterEntry;\r\n pieces: GearPiece[];\r\n onClick: () => void;\r\n}): ReactNode {\r\n const worn = hero.model?.Equipment?.[slot.id];\r\n const piece = worn?.ItemInstanceID\r\n ? pieces.find((p) => p.instanceID === worn.ItemInstanceID)\r\n : undefined;\r\n const locked = hero.rank < slot.minRank;\r\n const { play } = useUiKit();\r\n return (\r\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\r\n {worn?.ItemID ? (\r\n <ItemTile\r\n itemID={worn.ItemID}\r\n level={piece?.level}\r\n onClick={onClick}\r\n size={80}\r\n />\r\n ) : (\r\n <button\r\n type=\"button\"\r\n className=\"idos-press\"\r\n onClick={() => {\r\n play(\"click\");\r\n onClick();\r\n }}\r\n style={{\r\n width: 80,\r\n height: 80,\r\n borderRadius: 16,\r\n border: `3px dashed ${v.panelEdge}`,\r\n background: v.wellSoft,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n cursor: \"pointer\",\r\n opacity: locked ? 0.5 : 1,\r\n }}\r\n >\r\n <Icon glyph={locked ? \"lock\" : \"plus\"} size={30} />\r\n </button>\r\n )}\r\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {locked ? `${t(\"slotLocked\")} ${slot.minRank}` : slot.id}\r\n </span>\r\n </div>\r\n );\r\n}\r\n\r\nfunction Bag({\r\n pieces,\r\n onPick,\r\n onShop,\r\n}: {\r\n pieces: GearPiece[];\r\n onPick: (instanceID: string) => void;\r\n onShop?: () => void;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const stagger = useStagger();\r\n const classes = [\r\n ...new Set(\r\n pieces.map((p) => catalog.items.get(p.itemID)?.ItemClass || \"other\"),\r\n ),\r\n ];\r\n const [tab, setTab] = useState(\"all\");\r\n const shown =\r\n tab === \"all\"\r\n ? pieces\r\n : pieces.filter(\r\n (p) => (catalog.items.get(p.itemID)?.ItemClass || \"other\") === tab,\r\n );\r\n return (\r\n <div style={{ display: \"grid\", gap: 10 }}>\r\n <SectionTitle>{t(\"bag\")}</SectionTitle>\r\n {pieces.length === 0 ? (\r\n <EmptyState\r\n glyph=\"sword\"\r\n text={t(\"noGear\")}\r\n action={\r\n onShop ? (\r\n <Button onClick={onShop}>{catalog.localize(\"Shop\")}</Button>\r\n ) : undefined\r\n }\r\n />\r\n ) : (\r\n <>\r\n <Tabs\r\n tabs={[\r\n { id: \"all\", label: \"★\" },\r\n ...classes.map((c) => ({ id: c, label: c })),\r\n ]}\r\n value={tab}\r\n onChange={setTab}\r\n />\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(84px, 1fr))\",\r\n gap: 10,\r\n justifyItems: \"center\",\r\n }}\r\n >\r\n {shown.map((p, i) => (\r\n <div key={p.instanceID} style={stagger(i)}>\r\n <ItemTile\r\n itemID={p.itemID}\r\n level={p.level}\r\n onClick={() => onPick(p.instanceID)}\r\n />\r\n </div>\r\n ))}\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n );\r\n}\r\n\r\n// ── Popups ───────────────────────────────────────────────────────────────────────────────────\r\n\r\nfunction HeroesPopup({\r\n list,\r\n current,\r\n balances,\r\n onSelect,\r\n onClose,\r\n}: {\r\n list: RosterEntry[];\r\n current: string;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n onSelect: (id: string) => void;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const stagger = useStagger();\r\n const [busy, setBusy] = useState<string | null>(null);\r\n\r\n const unlock = async (e: RosterEntry) => {\r\n setBusy(e.id);\r\n const res = await client.character.unlockCharacter(e.id);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(\"levelUp\");\r\n onSelect(e.id);\r\n };\r\n\r\n return (\r\n <Popup title={t(\"heroes\")} onClose={onClose} width={560}>\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(150px, 1fr))\",\r\n gap: 10,\r\n }}\r\n >\r\n {list.map((e, i) => {\r\n const color = catalog.rarityColor(e.def.Classification?.RarityID);\r\n const affordable = canAfford(e.unlockPrice, balances);\r\n return (\r\n <Card\r\n key={e.id}\r\n highlight={e.id === current}\r\n style={{\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 6,\r\n opacity: e.state === \"locked\" ? 0.85 : 1,\r\n border: `2px solid ${e.id === current ? v.gold : color}`,\r\n ...stagger(i),\r\n }}\r\n >\r\n <div style={{ position: \"relative\" }}>\r\n <Icon\r\n glyph={e.def.Identity?.AssetPaths?.icon ?? \"hero\"}\r\n size={56}\r\n />\r\n {e.state === \"locked\" ? (\r\n <span style={{ position: \"absolute\", right: -8, bottom: -4 }}>\r\n <Icon glyph=\"lock\" size={22} />\r\n </span>\r\n ) : null}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 15 }}>\r\n {catalog.localize(e.def.Identity?.DisplayName ?? e.id)}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {e.state === \"locked\"\r\n ? t(\"locked\")\r\n : `${t(\"rank\")} ${e.rank} · ${t(\"power\")} ${formatAmount(e.power)}`}\r\n </div>\r\n {e.state === \"locked\" ? (\r\n e.unlockPrice.length > 0 ? (\r\n <Button\r\n tone=\"gold\"\r\n size=\"sm\"\r\n attract={affordable}\r\n disabled={!affordable}\r\n busy={busy === e.id}\r\n onClick={() => void unlock(e)}\r\n style={{ width: \"100%\" }}\r\n >\r\n {t(\"unlock\")}{\" \"}\r\n <ResourceList lines={e.unlockPrice} size={16} />\r\n </Button>\r\n ) : (\r\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\r\n {t(\"notForSale\")}\r\n </span>\r\n )\r\n ) : e.id === current ? (\r\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\r\n {t(\"selected\")}\r\n </span>\r\n ) : (\r\n <SelectButton onSelect={() => onSelect(e.id)} />\r\n )}\r\n </Card>\r\n );\r\n })}\r\n </div>\r\n </Popup>\r\n );\r\n}\r\n\r\nfunction SelectButton({ onSelect }: { onSelect: () => void }): ReactNode {\r\n const close = usePopupClose();\r\n return (\r\n <Button\r\n size=\"sm\"\r\n onClick={() => {\r\n onSelect();\r\n close();\r\n }}\r\n style={{ width: \"100%\" }}\r\n >\r\n {t(\"select\")}\r\n </Button>\r\n );\r\n}\r\n\r\nfunction StatsPopup({\r\n hero,\r\n section,\r\n pieces,\r\n items,\r\n balances,\r\n onClose,\r\n}: {\r\n hero: RosterEntry;\r\n section: CharacterSection | undefined;\r\n pieces: GearPiece[];\r\n items: Map<string, ItemDefWithGear>;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const play = useSound();\r\n const stagger = useStagger();\r\n const [busy, setBusy] = useState<string | null>(null);\r\n const [detail, setDetail] = useState<StatView | null>(null);\r\n const stats = resolveStats(section, hero.def);\r\n const levels = hero.model?.StatLevels ?? {};\r\n const worn = Object.values(hero.model?.Equipment ?? {})\r\n .filter((e) => e?.ItemInstanceID)\r\n .map((e) => {\r\n const p = pieces.find((x) => x.instanceID === e!.ItemInstanceID);\r\n return { def: items.get(e!.ItemID ?? \"\"), level: p?.level ?? 1 };\r\n });\r\n\r\n const upgrade = async (stat: StatView) => {\r\n setBusy(stat.id);\r\n const res = await client.character.upgradeStatLevel(hero.id, stat.id);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n play(\"levelUp\");\r\n };\r\n\r\n // \"Upgrade all\" (Unity CharacterStatPopup): as many levels as the balances cover, spread over the\r\n // stats cheapest-first, in ONE atomic batch.\r\n const plan = upgradeAllPlan(stats, levels, hero.rank, hero.def, balances);\r\n const upgradeAll = async () => {\r\n setBusy(\"all\");\r\n const res = await client.character.upgradeStatLevelsBatch(\r\n plan.map((p) => ({ CharacterID: hero.id, ...p })),\r\n );\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n play(\"levelUp\");\r\n };\r\n\r\n return (\r\n <Popup\r\n title={`${t(\"stats\")} · ${catalog.localize(hero.def.Identity?.DisplayName ?? hero.id)}`}\r\n onClose={onClose}\r\n width={480}\r\n >\r\n <div style={{ display: \"grid\", gap: 10 }}>\r\n <Button\r\n tone=\"gold\"\r\n attract={plan.length > 0}\r\n disabled={plan.length === 0}\r\n busy={busy === \"all\"}\r\n onClick={() => void upgradeAll()}\r\n >\r\n {t(\"upgradeAll\")}\r\n {plan.length > 0\r\n ? ` +${plan.reduce((n, p) => n + p.Levels, 0)}`\r\n : \"\"}\r\n </Button>\r\n {stats.map((stat, i) => {\r\n const level = Number(levels[stat.id] ?? 0);\r\n const cap = statMaxLevel(stat, hero.rank, hero.def);\r\n const capped = cap > 0 && level >= cap;\r\n const cost = statCost(stat, level + 1);\r\n const missing = unmetRequirements(stat, levels);\r\n const affordable = canAfford(cost, balances);\r\n const total = breakdown(stat, level, hero.rank, hero.def, worn).total;\r\n return (\r\n <Card\r\n key={stat.id}\r\n style={{ display: \"grid\", gap: 8, ...stagger(i) }}\r\n >\r\n <button\r\n type=\"button\"\r\n onClick={() => setDetail(stat)}\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n alignItems: \"center\",\r\n background: \"none\",\r\n border: \"none\",\r\n padding: 0,\r\n cursor: \"pointer\",\r\n color: v.text,\r\n }}\r\n >\r\n <span style={{ ...outlined, fontSize: 15 }}>\r\n {catalog.localize(stat.name)} ⓘ\r\n </span>\r\n <span style={{ ...outlined, fontSize: 18, color: v.gold }}>\r\n {statNumber(total)}\r\n </span>\r\n </button>\r\n {cap > 0 ? (\r\n <ProgressBar\r\n value={level}\r\n max={cap}\r\n label={`${t(\"level\")} ${level} / ${cap}`}\r\n height={16}\r\n />\r\n ) : null}\r\n {missing.length > 0 ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\r\n {t(\"needs\")}:{\" \"}\r\n {missing.map((m) => `${m.statID} ${m.level}`).join(\", \")}\r\n </div>\r\n ) : capped ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {t(\"statCapped\")}\r\n </div>\r\n ) : (\r\n <Button\r\n size=\"sm\"\r\n tone=\"green\"\r\n disabled={!affordable}\r\n busy={busy === stat.id}\r\n onClick={() => void upgrade(stat)}\r\n >\r\n {t(\"upgrade\")} →{\" \"}\r\n {statNumber(\r\n breakdown(stat, level + 1, hero.rank, hero.def, worn).total,\r\n )}{\" \"}\r\n <ResourceList\r\n lines={cost}\r\n size={16}\r\n dimIf={() => !affordable}\r\n />\r\n </Button>\r\n )}\r\n </Card>\r\n );\r\n })}\r\n </div>\r\n {detail ? (\r\n <BreakdownPopup\r\n stat={detail}\r\n level={Number(levels[detail.id] ?? 0)}\r\n hero={hero}\r\n worn={worn}\r\n onClose={() => setDetail(null)}\r\n />\r\n ) : null}\r\n </Popup>\r\n );\r\n}\r\n\r\nfunction BreakdownPopup({\r\n stat,\r\n level,\r\n hero,\r\n worn,\r\n onClose,\r\n}: {\r\n stat: StatView;\r\n level: number;\r\n hero: RosterEntry;\r\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const b = breakdown(stat, level, hero.rank, hero.def, worn);\r\n const rows: Array<[string, number, string]> = [\r\n [t(\"base\"), b.base, v.text],\r\n [t(\"fromLevel\"), b.fromLevel, v.green],\r\n [t(\"fromRank\"), b.fromRank, v.blue],\r\n [t(\"fromGear\"), b.fromGear, v.gold],\r\n ];\r\n const round = statNumber;\r\n return (\r\n <Popup\r\n title={`${catalog.localize(stat.name)} — ${t(\"where\")}`}\r\n onClose={onClose}\r\n width={380}\r\n variant=\"center\"\r\n >\r\n {stat.description ? (\r\n <div\r\n style={{\r\n ...outlined,\r\n fontWeight: 600,\r\n fontSize: 13,\r\n color: v.textDim,\r\n textAlign: \"center\",\r\n }}\r\n >\r\n {catalog.localize(stat.description)}\r\n </div>\r\n ) : null}\r\n <div style={{ display: \"grid\", gap: 6 }}>\r\n {rows.map(([label, value, color]) => (\r\n <div\r\n key={label}\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n ...outlined,\r\n fontSize: 15,\r\n }}\r\n >\r\n <span style={{ color: v.textDim }}>{label}</span>\r\n <span style={{ color }}>\r\n {value === 0 && label !== t(\"base\")\r\n ? \"—\"\r\n : `${label === t(\"base\") ? \"\" : \"+\"}${round(value)}`}\r\n </span>\r\n </div>\r\n ))}\r\n {b.percent ? (\r\n <div\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n ...outlined,\r\n fontSize: 15,\r\n }}\r\n >\r\n <span style={{ color: v.textDim }}>{t(\"percent\")}</span>\r\n <span style={{ color: v.gold }}>+{round(b.percent)}%</span>\r\n </div>\r\n ) : null}\r\n <div style={{ height: 2, background: v.panelEdge, margin: \"4px 0\" }} />\r\n <div\r\n style={{\r\n display: \"flex\",\r\n justifyContent: \"space-between\",\r\n ...outlined,\r\n fontSize: 18,\r\n }}\r\n >\r\n <span>{t(\"total\")}</span>\r\n <span style={{ color: v.gold }}>{round(b.total)}</span>\r\n </div>\r\n </div>\r\n </Popup>\r\n );\r\n}\r\n\r\nfunction SlotPopup({\r\n slot,\r\n hero,\r\n pieces,\r\n items,\r\n onPick,\r\n onClose,\r\n}: {\r\n slot: SlotView;\r\n hero: RosterEntry;\r\n pieces: GearPiece[];\r\n items: Map<string, ItemDefWithGear>;\r\n onPick: (instanceID: string) => void;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const worn = hero.model?.Equipment?.[slot.id]?.ItemInstanceID ?? null;\r\n const fitting = pieces.filter(\r\n (p) =>\r\n p.instanceID !== worn &&\r\n (!p.equippedBy || p.equippedBy.characterID === hero.id) &&\r\n fitProblem(p, slot, hero.id, hero.rank, items) === null,\r\n );\r\n const stagger = useStagger();\r\n return (\r\n <Popup title={slot.id} onClose={onClose} width={440}>\r\n {worn ? (\r\n <PickRow\r\n instanceID={worn}\r\n pieces={pieces}\r\n label={t(\"worn\")}\r\n onPick={onPick}\r\n />\r\n ) : null}\r\n {fitting.length === 0 ? (\r\n <EmptyState\r\n glyph=\"sword\"\r\n text={\r\n hero.rank < slot.minRank\r\n ? `${t(\"slotLocked\")} ${slot.minRank}`\r\n : t(\"noGear\")\r\n }\r\n />\r\n ) : (\r\n <div style={{ display: \"grid\", gap: 8 }}>\r\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\r\n {t(\"fits\")}\r\n </div>\r\n {fitting.map((p, i) => (\r\n <div key={p.instanceID} style={stagger(i)}>\r\n <PickRow\r\n instanceID={p.instanceID}\r\n pieces={pieces}\r\n onPick={onPick}\r\n />\r\n </div>\r\n ))}\r\n </div>\r\n )}\r\n </Popup>\r\n );\r\n}\r\n\r\nfunction PickRow({\r\n instanceID,\r\n pieces,\r\n label,\r\n onPick,\r\n}: {\r\n instanceID: string;\r\n pieces: GearPiece[];\r\n label?: string;\r\n onPick: (id: string) => void;\r\n}): ReactNode {\r\n const catalog = useCatalog();\r\n const piece = pieces.find((p) => p.instanceID === instanceID);\r\n if (!piece) return null;\r\n return (\r\n <Card\r\n onClick={() => onPick(instanceID)}\r\n style={{ display: \"flex\", alignItems: \"center\", gap: 12, padding: 10 }}\r\n >\r\n <ItemTile itemID={piece.itemID} level={piece.level} size={56} />\r\n <div style={{ flex: 1, minWidth: 0 }}>\r\n <div style={{ ...outlined, fontSize: 15 }}>\r\n {catalog.itemName(piece.itemID)}\r\n </div>\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {label ? `${label} · ` : \"\"}\r\n {t(\"level\")} {piece.level}\r\n </div>\r\n </div>\r\n </Card>\r\n );\r\n}\r\n\r\nfunction PiecePopup({\r\n instanceID,\r\n hero,\r\n slots,\r\n pieces,\r\n items,\r\n balances,\r\n onClose,\r\n}: {\r\n instanceID: string;\r\n hero: RosterEntry;\r\n slots: SlotView[];\r\n pieces: GearPiece[];\r\n items: Map<string, ItemDefWithGear>;\r\n balances: Record<string, { Amount?: number }> | undefined;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const iconRef = useRef<HTMLDivElement>(null);\r\n const [busy, setBusy] = useState<string | null>(null);\r\n const piece = pieces.find((p) => p.instanceID === instanceID);\r\n if (!piece) return null;\r\n const def = items.get(piece.itemID);\r\n const color = catalog.rarityColor(def?.Metadata?.RarityID);\r\n const bonuses = pieceBonuses(def, piece.level);\r\n const fitSlot = slots.find(\r\n (s) => fitProblem(piece, s, hero.id, hero.rank, items) === null,\r\n );\r\n const onThisHero =\r\n piece.equippedBy?.characterID === hero.id ? piece.equippedBy : null;\r\n const levelCost = itemLevelCost(def, piece.level);\r\n const affordable = levelCost\r\n ? canAfford(levelCost as ResourceLine[], balances)\r\n : false;\r\n\r\n const act = async (kind: \"equip\" | \"unequip\" | \"level\") => {\r\n setBusy(kind);\r\n const res =\r\n kind === \"equip\" && fitSlot\r\n ? await client.character.equipItems(hero.id, [\r\n { SlotID: fitSlot.id, ItemInstanceID: piece.instanceID },\r\n ])\r\n : kind === \"unequip\" && onThisHero\r\n ? await client.character.unequipItems(hero.id, [onThisHero.slotID])\r\n : await client.item.upgradeLevel(piece.instanceID);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(kind === \"level\" ? \"levelUp\" : \"claim\");\r\n kit.fx.burst(iconRef.current);\r\n };\r\n\r\n return (\r\n <Popup title={catalog.itemName(piece.itemID)} onClose={onClose}>\r\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\r\n <div\r\n ref={iconRef}\r\n className=\"idos-glow\"\r\n style={{\r\n width: 110,\r\n height: 110,\r\n borderRadius: 26,\r\n display: \"grid\",\r\n placeItems: \"center\",\r\n border: `4px solid ${color}`,\r\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\r\n }}\r\n >\r\n <Icon\r\n glyph={catalog.iconOf({\r\n kind: \"item\",\r\n id: piece.itemID,\r\n amount: 1,\r\n })}\r\n size={72}\r\n />\r\n </div>\r\n <div style={{ ...outlined, color, fontSize: 14 }}>\r\n {def?.Metadata?.RarityID ?? \"Common\"} · {t(\"level\")} {piece.level}\r\n {def?.Upgrade?.MaxLevel ? ` / ${def.Upgrade.MaxLevel}` : \"\"}\r\n </div>\r\n {Object.entries(bonuses).map(([stat, value]) => (\r\n <div key={stat} style={{ ...outlined, fontSize: 15, color: v.green }}>\r\n +{statNumber(value)} {stat}\r\n </div>\r\n ))}\r\n {Object.entries(def?.Stats?.PercentBonuses ?? {}).map(\r\n ([stat, value]) => (\r\n <div\r\n key={stat}\r\n style={{ ...outlined, fontSize: 15, color: v.gold }}\r\n >\r\n +{value}% {stat}\r\n </div>\r\n ),\r\n )}\r\n {piece.equippedBy && !onThisHero ? (\r\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\r\n {t(\"wornBy\")} {piece.equippedBy.characterID}\r\n </div>\r\n ) : null}\r\n </div>\r\n <div style={{ display: \"grid\", gap: 8 }}>\r\n {onThisHero ? (\r\n <Button\r\n tone=\"grey\"\r\n busy={busy === \"unequip\"}\r\n onClick={() => void act(\"unequip\")}\r\n >\r\n {t(\"unequip\")}\r\n </Button>\r\n ) : fitSlot ? (\r\n <Button\r\n tone=\"blue\"\r\n busy={busy === \"equip\"}\r\n onClick={() => void act(\"equip\")}\r\n >\r\n {t(\"equip\")} → {fitSlot.id}\r\n </Button>\r\n ) : (\r\n <div\r\n style={{\r\n ...outlined,\r\n fontSize: 13,\r\n color: v.textDim,\r\n textAlign: \"center\",\r\n }}\r\n >\r\n {t(\"cannot\")}\r\n </div>\r\n )}\r\n {levelCost === null ? (\r\n def?.Upgrade ? (\r\n <div\r\n style={{\r\n ...outlined,\r\n fontSize: 13,\r\n color: v.textDim,\r\n textAlign: \"center\",\r\n }}\r\n >\r\n {t(\"maxLevel\")}\r\n </div>\r\n ) : null\r\n ) : (\r\n <Button\r\n tone=\"gold\"\r\n disabled={!affordable}\r\n busy={busy === \"level\"}\r\n onClick={() => void act(\"level\")}\r\n >\r\n {t(\"levelUp\")}{\" \"}\r\n <ResourceList\r\n lines={levelCost}\r\n size={18}\r\n dimIf={() => !affordable}\r\n />\r\n </Button>\r\n )}\r\n </div>\r\n </Popup>\r\n );\r\n}\r\n\r\nfunction SkinsPopup({\r\n hero,\r\n onClose,\r\n}: {\r\n hero: RosterEntry;\r\n onClose: () => void;\r\n}): ReactNode {\r\n const client = useIDosGamesClient();\r\n const catalog = useCatalog();\r\n const toast = useToast();\r\n const kit = useUiKit();\r\n const stagger = useStagger();\r\n const [view, setView] = useState<{\r\n WornSkinID?: string | null;\r\n Skins?: Array<{\r\n SkinID: string;\r\n Owned?: boolean | null;\r\n IsWorn?: boolean | null;\r\n Purchasable?: boolean | null;\r\n MeetsRequirements?: boolean | null;\r\n PriceOptions?: Record<string, { Cost?: unknown }> | null;\r\n }> | null;\r\n } | null>(null);\r\n const [busy, setBusy] = useState<string | null>(null);\r\n\r\n const load = async () => {\r\n const res = await client.character.getCharacterSkins(hero.id);\r\n if (res.ok) setView(res.data.Characters?.[hero.id] ?? null);\r\n };\r\n useEffect(() => {\r\n void load();\r\n // once per hero\r\n }, [hero.id]);\r\n\r\n const act = async (skinID: string, kind: \"wear\" | \"buy\" | \"base\") => {\r\n setBusy(skinID);\r\n const res =\r\n kind === \"buy\"\r\n ? await client.character.unlockSkin(hero.id, skinID, {\r\n autoEquip: true,\r\n })\r\n : kind === \"base\"\r\n ? await client.character.unequipSkin(hero.id)\r\n : await client.character.equipSkin(hero.id, skinID);\r\n setBusy(null);\r\n if (!res.ok) return toast(errorText(res.error), \"error\");\r\n kit.play(kind === \"buy\" ? \"purchase\" : \"claim\");\r\n await load();\r\n };\r\n\r\n const defs = hero.def.Skins?.Definitions ?? {};\r\n const wornID = hero.model?.Equipment?.[SKIN_KEY]?.ItemID ?? null;\r\n return (\r\n <Popup title={t(\"skins\")} onClose={onClose} width={520}>\r\n <div\r\n style={{\r\n display: \"grid\",\r\n gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",\r\n gap: 10,\r\n }}\r\n >\r\n <Card\r\n highlight={!wornID}\r\n style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}\r\n >\r\n <Icon glyph=\"hero\" size={52} />\r\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"baseLook\")}</div>\r\n {wornID ? (\r\n <Button\r\n size=\"sm\"\r\n busy={busy === \"@base\"}\r\n onClick={() => void act(\"@base\", \"base\")}\r\n >\r\n {t(\"wear\")}\r\n </Button>\r\n ) : (\r\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\r\n {t(\"worn\")}\r\n </span>\r\n )}\r\n </Card>\r\n {(view?.Skins ?? []).map((s, i) => {\r\n const def = defs[s.SkinID];\r\n const price = Object.values(s.PriceOptions ?? {})[0];\r\n return (\r\n <Card\r\n key={s.SkinID}\r\n highlight={s.IsWorn === true}\r\n style={{\r\n display: \"grid\",\r\n justifyItems: \"center\",\r\n gap: 6,\r\n ...stagger(i),\r\n }}\r\n >\r\n <Icon\r\n glyph={def?.Identity?.AssetPaths?.icon ?? \"user\"}\r\n size={52}\r\n />\r\n <div style={{ ...outlined, fontSize: 14 }}>\r\n {catalog.localize(def?.Identity?.DisplayName ?? s.SkinID)}\r\n </div>\r\n {s.IsWorn ? (\r\n <span style={{ ...outlined, fontSize: 13, color: v.gold }}>\r\n {t(\"worn\")}\r\n </span>\r\n ) : s.Owned ? (\r\n <Button\r\n size=\"sm\"\r\n disabled={s.MeetsRequirements === false}\r\n busy={busy === s.SkinID}\r\n onClick={() => void act(s.SkinID, \"wear\")}\r\n >\r\n {t(\"wear\")}\r\n </Button>\r\n ) : s.Purchasable && price ? (\r\n <Button\r\n size=\"sm\"\r\n tone=\"gold\"\r\n busy={busy === s.SkinID}\r\n onClick={() => void act(s.SkinID, \"buy\")}\r\n >\r\n <ResourceList\r\n lines={costLines(price.Cost as never)}\r\n size={16}\r\n />\r\n </Button>\r\n ) : (\r\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\r\n {t(\"notForSale\")}\r\n </span>\r\n )}\r\n </Card>\r\n );\r\n })}\r\n </div>\r\n </Popup>\r\n );\r\n}\r\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "events.ts",
|
|
69
|
+
"content": "import { defineTopic, shape } from \"@idosgames/module-sdk\";\n\n// Topics this module EMITS. Declared in module.meta.json (`events.emits`) with the same descriptor,\n// so other modules can copy it from the catalog — they never import this file.\n\n/**\n * The hero the player plays with (Unity: CharactersSave.SelectedCharacterId — the hero a run is\n * launched with). Emitted once on start-up with the saved choice, and again whenever the player picks\n * another hero on the Heroes screen. A game listens to it to field that hero.\n */\nexport const heroSelected = defineTopic(\n \"character:hero-selected@1\",\n shape({ characterId: \"string\" }),\n);\n"
|
|
52
70
|
},
|
|
53
71
|
{
|
|
54
72
|
"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"
|
|
73
|
+
"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 unequipAll: \"Take off all\",\n upgradeAll: \"Upgrade all\",\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 unequipAll: \"Снять всё\",\n upgradeAll: \"Улучшить всё\",\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
74
|
},
|
|
57
75
|
{
|
|
58
76
|
"path": "index.ts",
|
|
@@ -60,15 +78,19 @@
|
|
|
60
78
|
},
|
|
61
79
|
{
|
|
62
80
|
"path": "model.ts",
|
|
63
|
-
"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\n/** Whether the title has heroes at all — until it does, the lobby hides the Heroes tab. */\nexport function hasTitleData(section: CharacterSection | undefined): boolean {\n return Object.keys(section?.Definitions ?? {}).length > 0;\n}\n"
|
|
81
|
+
"content": "// Pure helpers of the heroes screen (Unity IdleRPG/Runtime/Character + Equipment): the roster,\r\n// merged stats/ladder/slots (preset base + inline override, Remove drops keys — the server's rule),\r\n// prices of ranks, stats and item levels, which gear fits a slot, the best loadout, and where a\r\n// stat's number comes from. No React, no client calls — covered by model.test.ts.\r\n//\r\n// The server is the authority on every number: these only drive what the screen shows before the\r\n// player taps, and a rejected action is surfaced as the server's error.\r\n\r\nimport {\r\n curveMultiplier,\r\n evaluateCurve,\r\n roundAmount,\r\n type ScalarCurveSpec,\r\n} from \"@idosgames/core\";\r\nimport {\r\n costLines,\r\n formatAmount,\r\n rarityRank,\r\n type ItemDefinitionView,\r\n type ResourceLine,\r\n} from \"@idosgames/react/ui\";\r\n\r\n// ── Config views ─────────────────────────────────────────────────────────────────────────────\r\n\r\ntype PriceOptionsView =\r\n | Record<string, { OptionID?: string | null; Cost?: unknown } | null>\r\n | null\r\n | undefined;\r\n\r\nexport interface StatDefView {\r\n StatID?: string;\r\n DisplayName?: string | null;\r\n Description?: string | null;\r\n MaxLevel?: number | null;\r\n BaseStatValue?: number | null;\r\n ValueCurve?: ScalarCurveSpec | null;\r\n RankCurve?: ScalarCurveSpec | null;\r\n CostCurve?: ScalarCurveSpec | null;\r\n PriceOptions?: PriceOptionsView;\r\n Requirements?: Array<{\r\n RequiredStatID?: string;\r\n RequiredLevel?: number;\r\n } | null> | null;\r\n}\r\n\r\nexport interface SlotRuleView {\r\n SlotID?: string;\r\n MinCharacterLevel?: number | null;\r\n AllowedItemTags?: string[] | null;\r\n AllowedRarityIDs?: string[] | null;\r\n MinItemLevel?: number | null;\r\n MaxItemLevel?: number | null;\r\n}\r\n\r\nexport interface RankLadderView {\r\n MaxRank?: number | null;\r\n FirstPaidRank?: number | null;\r\n PriceOptions?: PriceOptionsView;\r\n CostCurve?: ScalarCurveSpec | null;\r\n}\r\n\r\ninterface Binding {\r\n PresetID?: string | null;\r\n Remove?: string[] | null;\r\n}\r\n\r\nexport interface SkinDefView {\r\n SkinID?: string;\r\n Identity?: {\r\n DisplayName?: string | null;\r\n SortOrder?: number | null;\r\n AssetPaths?: Record<string, string> | null;\r\n } | null;\r\n Item?: { CatalogID?: string | null; ItemID?: string | null } | null;\r\n}\r\n\r\nexport interface CharacterDefView {\r\n CharacterID?: string;\r\n Identity?: {\r\n DisplayName?: string | null;\r\n Description?: string | null;\r\n SortOrder?: number | null;\r\n AssetPaths?: Record<string, string> | null;\r\n } | null;\r\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\r\n Unlock?: {\r\n UnlockedByDefault?: boolean | null;\r\n PriceOptions?: PriceOptionsView;\r\n } | null;\r\n Stats?: Record<string, StatDefView | null> | null;\r\n RankLadder?: RankLadderView | null;\r\n RankStatCurve?: ScalarCurveSpec | null;\r\n StatMaxLevelCurve?: ScalarCurveSpec | null;\r\n Equipment?: { Slots?: Record<string, SlotRuleView | null> | null } | null;\r\n Skins?: {\r\n DefaultSkinID?: string | null;\r\n Definitions?: Record<string, SkinDefView | null> | null;\r\n } | null;\r\n Presets?: {\r\n Stats?: Binding | null;\r\n Levels?: Binding | null;\r\n Equipment?: Binding | null;\r\n } | null;\r\n}\r\n\r\nexport interface CharacterSection {\r\n Definitions?: Record<string, CharacterDefView | null> | null;\r\n Presets?: {\r\n Stats?: Record<\r\n string,\r\n { Stats?: Record<string, StatDefView | null> | null } | null\r\n > | null;\r\n Levels?: Record<\r\n string,\r\n { RankLadder?: RankLadderView | null } | null\r\n > | null;\r\n Equipment?: Record<\r\n string,\r\n {\r\n Equipment?: {\r\n Slots?: Record<string, SlotRuleView | null> | null;\r\n } | null;\r\n } | null\r\n > | null;\r\n } | null;\r\n}\r\n\r\n// ── State views ──────────────────────────────────────────────────────────────────────────────\r\n\r\nexport interface CharacterModelView {\r\n CharacterID?: string;\r\n Level?: number | null;\r\n Power?: number | null;\r\n StatLevels?: Record<string, number> | null;\r\n Equipment?: Record<\r\n string,\r\n { ItemID?: string | null; ItemInstanceID?: string | null } | null\r\n > | null;\r\n}\r\n\r\nexport interface InstanceView {\r\n ItemID?: string;\r\n Level?: number | null;\r\n EquippedSlot?: { CharacterID?: string | null; SlotID?: string | null } | null;\r\n}\r\n\r\nexport interface ItemDefWithGear extends ItemDefinitionView {\r\n Stats?: {\r\n FlatBonuses?: Record<string, number> | null;\r\n PercentBonuses?: Record<string, number> | null;\r\n Power?: number | null;\r\n } | null;\r\n Equipment?: {\r\n MinCharacterLevel?: number | null;\r\n AllowedSlotIDs?: string[] | null;\r\n AllowedCharacterIDs?: string[] | null;\r\n } | null;\r\n Upgrade?: {\r\n MaxLevel?: number | null;\r\n PriceOptions?: PriceOptionsView;\r\n CostCurve?: ScalarCurveSpec | null;\r\n FlatBonusCurve?: ScalarCurveSpec | null;\r\n PowerCurve?: ScalarCurveSpec | null;\r\n } | null;\r\n}\r\n\r\n/** The reserved equipment key of the worn skin (SKIN_SLOT in @idosgames/core). */\r\nexport const SKIN_KEY = \"@skin\";\r\n\r\n// ── Presets ──────────────────────────────────────────────────────────────────────────────────\r\n\r\n/** Preset base + inline override/add by key, Remove drops keys. No preset → inline as-is. */\r\nexport function mergeByKey<T>(\r\n preset: Record<string, T | null> | null | undefined,\r\n inline: Record<string, T | null> | null | undefined,\r\n remove: readonly string[] | null | undefined,\r\n): Record<string, T> {\r\n const out: Record<string, T> = {};\r\n for (const [k, v] of Object.entries(preset ?? {})) if (v) out[k] = v;\r\n for (const [k, v] of Object.entries(inline ?? {})) if (v) out[k] = v;\r\n for (const k of remove ?? []) delete out[k];\r\n return out;\r\n}\r\n\r\n/** First way to pay → lines (\"default cost\", like the server's PriceOptionSelector.DefaultCost). */\r\nexport function firstPrice(options: PriceOptionsView): ResourceLine[] {\r\n const first = Object.values(options ?? {}).find((o) => o);\r\n return first ? costLines(first.Cost as never) : [];\r\n}\r\n\r\nfunction scale(lines: ResourceLine[], multiplier: number): ResourceLine[] {\r\n return lines.map((l) => ({\r\n ...l,\r\n amount: roundAmount(l.amount * multiplier),\r\n }));\r\n}\r\n\r\n// ── Roster ───────────────────────────────────────────────────────────────────────────────────\r\n\r\nexport type HeroState = \"owned\" | \"available\" | \"locked\";\r\n\r\nexport interface RosterEntry {\r\n id: string;\r\n def: CharacterDefView;\r\n model: CharacterModelView | undefined;\r\n state: HeroState;\r\n rank: number;\r\n power: number;\r\n unlockPrice: ResourceLine[];\r\n}\r\n\r\n/** Heroes in roster order (SortOrder, then id), each with its state for this player. */\r\nexport function roster(\r\n section: CharacterSection | undefined,\r\n owned:\r\n Record<string, CharacterModelView | null | undefined> | null | undefined,\r\n): RosterEntry[] {\r\n return Object.entries(section?.Definitions ?? {})\r\n .map(([id, def]) => ({ id: def?.CharacterID ?? id, def }))\r\n .filter((x): x is { id: string; def: CharacterDefView } => !!x.def)\r\n .sort(\r\n (a, b) =>\r\n Number(a.def.Identity?.SortOrder ?? 0) -\r\n Number(b.def.Identity?.SortOrder ?? 0) || a.id.localeCompare(b.id),\r\n )\r\n .map(({ id, def }) => {\r\n const model = owned?.[id] ?? undefined;\r\n const rank = Number(model?.Level ?? 0);\r\n const state: HeroState =\r\n rank >= 1\r\n ? \"owned\"\r\n : def.Unlock?.UnlockedByDefault\r\n ? \"available\"\r\n : \"locked\";\r\n return {\r\n id,\r\n def,\r\n model,\r\n state,\r\n rank: Math.max(rank, state === \"available\" ? 1 : 0),\r\n power: Number(model?.Power ?? 0),\r\n unlockPrice: firstPrice(def.Unlock?.PriceOptions),\r\n };\r\n });\r\n}\r\n\r\n// ── Stats ────────────────────────────────────────────────────────────────────────────────────\r\n\r\nexport interface StatView {\r\n id: string;\r\n name: string;\r\n description: string;\r\n maxLevel: number;\r\n base: number;\r\n valueCurve?: ScalarCurveSpec | null;\r\n rankCurve?: ScalarCurveSpec | null;\r\n costCurve?: ScalarCurveSpec | null;\r\n price: ResourceLine[];\r\n requirements: Array<{ statID: string; level: number }>;\r\n}\r\n\r\nexport function resolveStats(\r\n section: CharacterSection | undefined,\r\n def: CharacterDefView | undefined,\r\n): StatView[] {\r\n if (!def) return [];\r\n const binding = def.Presets?.Stats;\r\n const preset = binding?.PresetID\r\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\r\n : undefined;\r\n return Object.entries(mergeByKey(preset, def.Stats, binding?.Remove)).map(\r\n ([id, s]) => ({\r\n id: s.StatID ?? id,\r\n name: s.DisplayName || id,\r\n description: s.Description || \"\",\r\n maxLevel: Number(s.MaxLevel ?? 0),\r\n base: Number(s.BaseStatValue ?? 0),\r\n valueCurve: s.ValueCurve,\r\n rankCurve: s.RankCurve,\r\n costCurve: s.CostCurve,\r\n price: firstPrice(s.PriceOptions),\r\n requirements: (s.Requirements ?? [])\r\n .filter((r) => r?.RequiredStatID)\r\n .map((r) => ({\r\n statID: r!.RequiredStatID!,\r\n level: Number(r!.RequiredLevel ?? 0),\r\n })),\r\n }),\r\n );\r\n}\r\n\r\n/**\r\n * Stat value at stat-level `level` for a hero of rank `rank`: the stat's ValueCurve from 0, its\r\n * RankCurve and the hero's RankStatCurve from 1 — the order the server composes them in.\r\n */\r\nexport function statValue(\r\n stat: StatView,\r\n level: number,\r\n rank: number,\r\n def?: CharacterDefView,\r\n): number {\r\n const own = evaluateCurve(\r\n stat.valueCurve ?? undefined,\r\n stat.base,\r\n Math.max(0, level),\r\n 0,\r\n );\r\n const byRank = evaluateCurve(\r\n stat.rankCurve ?? undefined,\r\n own,\r\n Math.max(1, rank),\r\n 1,\r\n );\r\n return evaluateCurve(\r\n def?.RankStatCurve ?? undefined,\r\n byRank,\r\n Math.max(1, rank),\r\n 1,\r\n );\r\n}\r\n\r\n/**\r\n * A stat's number on screen: small ones keep their fraction (a 0.12 crit chance, 1.05 attacks per\r\n * second — rounding them to a tenth showed \"0\" and \"1\"), big ones go through the kit's formatter.\r\n */\r\nexport function statNumber(value: number): string {\r\n if (Math.abs(value) < 10 && !Number.isInteger(value))\r\n return value.toFixed(2).replace(/\\.?0+$/, \"\");\r\n return formatAmount(Math.round(value * 10) / 10);\r\n}\r\n\r\n/** The effective cap of a stat at a rank: MaxLevel through the hero's StatMaxLevelCurve. */\r\nexport function statMaxLevel(\r\n stat: StatView,\r\n rank: number,\r\n def?: CharacterDefView,\r\n): number {\r\n if (stat.maxLevel <= 0) return 0;\r\n return Math.ceil(\r\n evaluateCurve(\r\n def?.StatMaxLevelCurve ?? undefined,\r\n stat.maxLevel,\r\n Math.max(1, rank),\r\n 1,\r\n ),\r\n );\r\n}\r\n\r\n/** What raising a stat TO `nextLevel` costs (base price through CostCurve, rounded up once). */\r\nexport function statCost(stat: StatView, nextLevel: number): ResourceLine[] {\r\n return scale(\r\n stat.price,\r\n curveMultiplier(stat.costCurve ?? undefined, Math.max(1, nextLevel), 1),\r\n );\r\n}\r\n\r\n/** Requirements of a stat the hero has not met yet. */\r\nexport function unmetRequirements(\r\n stat: StatView,\r\n levels: Record<string, number> | null | undefined,\r\n): Array<{ statID: string; level: number }> {\r\n return stat.requirements.filter(\r\n (r) => Number(levels?.[r.statID] ?? 0) < r.level,\r\n );\r\n}\r\n\r\n/** Most levels one \"Upgrade all\" buys (Unity CharacterStatPopup.MaxBatchTotalLevels). */\r\nexport const UPGRADE_ALL_MAX_LEVELS = 1000;\r\n\r\n/**\r\n * \"Upgrade all\" (Unity CharacterStatPopup.CollectUpgradePlan): spend the balances on as many stat\r\n * levels as they cover, always buying the globally CHEAPEST next level — so a shared currency spreads\r\n * across the stats instead of pouring into the first one. Skips stats at their cap, without a price,\r\n * or with an unmet requirement (the batch is all-or-nothing on the server: one refused stat would\r\n * void the whole purchase). Returns one entry per stat that gains a level.\r\n */\r\nexport function upgradeAllPlan(\r\n stats: StatView[],\r\n levels: Record<string, number> | null | undefined,\r\n rank: number,\r\n def: CharacterDefView | undefined,\r\n balances:\r\n Record<string, { Amount?: number } | null | undefined> | null | undefined,\r\n): Array<{ StatID: string; Levels: number }> {\r\n const left = new Map<string, number>();\r\n const plans = stats\r\n .filter(\r\n (s) => s.price.length > 0 && unmetRequirements(s, levels).length === 0,\r\n )\r\n .map((s) => {\r\n const from = Number(levels?.[s.id] ?? 0);\r\n return { stat: s, from, to: from, cap: statMaxLevel(s, rank, def) };\r\n });\r\n for (const p of plans)\r\n for (const line of p.stat.price)\r\n if (line.kind === \"currency\" && !left.has(line.id))\r\n left.set(line.id, Number(balances?.[line.id]?.Amount ?? 0));\r\n\r\n const affordable = (cost: ResourceLine[]) =>\r\n cost.length > 0 &&\r\n cost.every(\r\n (l) => l.kind === \"currency\" && (left.get(l.id) ?? 0) >= l.amount,\r\n );\r\n\r\n type Pick = { plan: (typeof plans)[number]; cost: ResourceLine[] };\r\n for (let n = 0; n < UPGRADE_ALL_MAX_LEVELS; n++) {\r\n let best: (Pick & { total: number }) | null = null;\r\n for (const p of plans) {\r\n if (p.cap > 0 && p.to >= p.cap) continue;\r\n const cost = statCost(p.stat, p.to + 1);\r\n if (!affordable(cost)) continue;\r\n const total = cost.reduce((s, l) => s + l.amount, 0);\r\n if (!best || total < best.total) best = { plan: p, cost, total };\r\n }\r\n if (!best) break;\r\n best.plan.to += 1;\r\n for (const l of best.cost) left.set(l.id, (left.get(l.id) ?? 0) - l.amount);\r\n }\r\n return plans\r\n .filter((p) => p.to > p.from)\r\n .map((p) => ({ StatID: p.stat.id, Levels: p.to - p.from }));\r\n}\r\n\r\n// ── Ranks ────────────────────────────────────────────────────────────────────────────────────\r\n\r\nexport function resolveLadder(\r\n section: CharacterSection | undefined,\r\n def: CharacterDefView | undefined,\r\n): RankLadderView | null {\r\n if (!def) return null;\r\n if (def.RankLadder) return def.RankLadder;\r\n const id = def.Presets?.Levels?.PresetID;\r\n return id ? (section?.Presets?.Levels?.[id]?.RankLadder ?? null) : null;\r\n}\r\n\r\n/** What the next rank costs; `null` — the hero cannot rank up (no ladder, or at the ceiling). */\r\nexport function rankCost(\r\n ladder: RankLadderView | null,\r\n currentRank: number,\r\n): ResourceLine[] | null {\r\n if (!ladder) return null;\r\n const next = currentRank + 1;\r\n if (ladder.MaxRank && next > ladder.MaxRank) return null;\r\n const firstPaid = Math.max(1, Number(ladder.FirstPaidRank ?? 1));\r\n if (next < firstPaid) return [];\r\n return scale(\r\n firstPrice(ladder.PriceOptions),\r\n curveMultiplier(ladder.CostCurve ?? undefined, next, firstPaid),\r\n );\r\n}\r\n\r\n// ── Equipment ────────────────────────────────────────────────────────────────────────────────\r\n\r\nexport interface SlotView {\r\n id: string;\r\n minRank: number;\r\n rule: SlotRuleView;\r\n}\r\n\r\n/** Gear slots of a hero (the reserved \"@…\" keys — the worn skin — are not slots). */\r\nexport function resolveSlots(\r\n section: CharacterSection | undefined,\r\n def: CharacterDefView | undefined,\r\n): SlotView[] {\r\n if (!def) return [];\r\n const binding = def.Presets?.Equipment;\r\n const preset = binding?.PresetID\r\n ? section?.Presets?.Equipment?.[binding.PresetID]?.Equipment?.Slots\r\n : undefined;\r\n return Object.entries(\r\n mergeByKey(preset, def.Equipment?.Slots, binding?.Remove),\r\n )\r\n .filter(([id]) => !id.startsWith(\"@\"))\r\n .map(([id, rule]) => ({\r\n id: rule.SlotID ?? id,\r\n minRank: Number(rule.MinCharacterLevel ?? 0),\r\n rule,\r\n }));\r\n}\r\n\r\nexport interface GearPiece {\r\n instanceID: string;\r\n itemID: string;\r\n level: number;\r\n equippedBy: { characterID: string; slotID: string } | null;\r\n}\r\n\r\n/** Every gear instance the player owns (unstackable items that declare equip rules). */\r\nexport function gearPieces(\r\n instances: Record<string, InstanceView | null> | null | undefined,\r\n items: Map<string, ItemDefWithGear>,\r\n): GearPiece[] {\r\n return Object.entries(instances ?? {})\r\n .filter(([, i]) => i?.ItemID && items.get(i.ItemID)?.Equipment)\r\n .map(([id, i]) => ({\r\n instanceID: id,\r\n itemID: i!.ItemID!,\r\n level: Number(i!.Level ?? 1) || 1,\r\n equippedBy:\r\n i!.EquippedSlot?.CharacterID && i!.EquippedSlot.SlotID\r\n ? {\r\n characterID: i!.EquippedSlot.CharacterID,\r\n slotID: i!.EquippedSlot.SlotID,\r\n }\r\n : null,\r\n }));\r\n}\r\n\r\n/** Why a piece cannot go into a slot of this hero, or null when it fits (both sides of the matrix). */\r\nexport function fitProblem(\r\n piece: GearPiece,\r\n slot: SlotView,\r\n heroID: string,\r\n rank: number,\r\n items: Map<string, ItemDefWithGear>,\r\n): \"slot\" | \"rank\" | \"rarity\" | \"tags\" | \"level\" | \"hero\" | null {\r\n const def = items.get(piece.itemID);\r\n const allowed = def?.Equipment?.AllowedSlotIDs ?? [];\r\n if (allowed.length > 0 && !allowed.includes(slot.id)) return \"slot\";\r\n if (\r\n def?.Equipment?.AllowedCharacterIDs?.length &&\r\n !def.Equipment.AllowedCharacterIDs.includes(heroID)\r\n )\r\n return \"hero\";\r\n if (\r\n rank < slot.minRank ||\r\n rank < Number(def?.Equipment?.MinCharacterLevel ?? 0)\r\n )\r\n return \"rank\";\r\n const r = slot.rule;\r\n if (\r\n r.AllowedRarityIDs?.length &&\r\n !r.AllowedRarityIDs.includes(def?.Metadata?.RarityID ?? \"\")\r\n )\r\n return \"rarity\";\r\n if (\r\n r.AllowedItemTags?.length &&\r\n !(def?.Tags ?? []).some((t) => r.AllowedItemTags!.includes(t))\r\n )\r\n return \"tags\";\r\n if ((r.MinItemLevel ?? 0) > 0 && piece.level < (r.MinItemLevel ?? 0))\r\n return \"level\";\r\n if ((r.MaxItemLevel ?? 0) > 0 && piece.level > (r.MaxItemLevel ?? 0))\r\n return \"level\";\r\n return null;\r\n}\r\n\r\n/** A comparable strength of a piece: its Power by level, else the sum of its flat bonuses. */\r\nexport function gearScore(\r\n piece: GearPiece,\r\n items: Map<string, ItemDefWithGear>,\r\n): number {\r\n const def = items.get(piece.itemID);\r\n const power = Number(def?.Stats?.Power ?? 0);\r\n if (power > 0)\r\n return (\r\n power *\r\n curveMultiplier(def?.Upgrade?.PowerCurve ?? undefined, piece.level, 1)\r\n );\r\n const flat = Object.values(def?.Stats?.FlatBonuses ?? {}).reduce(\r\n (s, n) => s + Number(n || 0),\r\n 0,\r\n );\r\n return (\r\n flat *\r\n curveMultiplier(\r\n def?.Upgrade?.FlatBonusCurve ?? undefined,\r\n piece.level,\r\n 1,\r\n ) +\r\n (4 - Math.min(4, rarityRank(def?.Metadata?.RarityID))) * 0.01\r\n );\r\n}\r\n\r\n/**\r\n * \"Equip best\" (Unity EquipmentAutoAssign): per unlocked slot, the strongest fitting piece not worn\r\n * by another hero and not taken by an earlier slot. Returns only the slots that would CHANGE.\r\n */\r\nexport function bestLoadout(\r\n heroID: string,\r\n rank: number,\r\n slots: SlotView[],\r\n pieces: GearPiece[],\r\n current: CharacterModelView[\"Equipment\"],\r\n items: Map<string, ItemDefWithGear>,\r\n): Array<{ SlotID: string; ItemInstanceID: string }> {\r\n const taken = new Set<string>();\r\n const changes: Array<{ SlotID: string; ItemInstanceID: string }> = [];\r\n for (const slot of slots) {\r\n const candidates = pieces\r\n .filter((p) => !taken.has(p.instanceID))\r\n .filter((p) => !p.equippedBy || p.equippedBy.characterID === heroID)\r\n .filter((p) => fitProblem(p, slot, heroID, rank, items) === null)\r\n .sort(\r\n (a, b) =>\r\n gearScore(b, items) - gearScore(a, items) ||\r\n a.instanceID.localeCompare(b.instanceID),\r\n );\r\n const best = candidates[0];\r\n if (!best) continue;\r\n taken.add(best.instanceID);\r\n const worn = current?.[slot.id]?.ItemInstanceID ?? null;\r\n const wornPiece = worn\r\n ? pieces.find((p) => p.instanceID === worn)\r\n : undefined;\r\n if (worn === best.instanceID) continue;\r\n if (wornPiece && gearScore(wornPiece, items) >= gearScore(best, items)) {\r\n taken.add(worn!);\r\n continue;\r\n }\r\n changes.push({ SlotID: slot.id, ItemInstanceID: best.instanceID });\r\n }\r\n return changes;\r\n}\r\n\r\n/** What raising an item instance TO `nextLevel` costs; null — at its cap or not upgradable. */\r\nexport function itemLevelCost(\r\n def: ItemDefWithGear | undefined,\r\n currentLevel: number,\r\n): ResourceLine[] | null {\r\n const up = def?.Upgrade;\r\n if (!up) return null;\r\n const next = currentLevel + 1;\r\n if ((up.MaxLevel ?? 0) > 0 && next > (up.MaxLevel ?? 0)) return null;\r\n return scale(\r\n firstPrice(up.PriceOptions),\r\n curveMultiplier(up.CostCurve ?? undefined, next, 1),\r\n );\r\n}\r\n\r\n/** Flat bonuses of a piece at its level (the item's FlatBonusCurve), by stat id. */\r\nexport function pieceBonuses(\r\n def: ItemDefWithGear | undefined,\r\n level: number,\r\n): Record<string, number> {\r\n const k = curveMultiplier(\r\n def?.Upgrade?.FlatBonusCurve ?? undefined,\r\n Math.max(1, level),\r\n 1,\r\n );\r\n return Object.fromEntries(\r\n Object.entries(def?.Stats?.FlatBonuses ?? {}).map(([id, n]) => [\r\n id,\r\n Number(n || 0) * k,\r\n ]),\r\n );\r\n}\r\n\r\n// ── \"Where does this number come from\" (Unity StatBreakdownPopup) ────────────────────────────\r\n\r\nexport interface Breakdown {\r\n base: number;\r\n fromLevel: number;\r\n fromRank: number;\r\n fromGear: number;\r\n percent: number;\r\n total: number;\r\n}\r\n\r\n/**\r\n * The parts of a stat: the base, what its own levels add, what the rank adds, flat bonuses of the\r\n * worn gear, then their percent bonuses — added in that order, so the parts sum to the total.\r\n */\r\nexport function breakdown(\r\n stat: StatView,\r\n level: number,\r\n rank: number,\r\n def: CharacterDefView | undefined,\r\n worn: Array<{ def: ItemDefWithGear | undefined; level: number }>,\r\n): Breakdown {\r\n const base = stat.base;\r\n const withLevel = evaluateCurve(\r\n stat.valueCurve ?? undefined,\r\n base,\r\n Math.max(0, level),\r\n 0,\r\n );\r\n const withRank = statValue(stat, level, rank, def);\r\n const fromGear = worn.reduce(\r\n (s, w) => s + (pieceBonuses(w.def, w.level)[stat.id] ?? 0),\r\n 0,\r\n );\r\n const percent = worn.reduce(\r\n (s, w) => s + Number(w.def?.Stats?.PercentBonuses?.[stat.id] ?? 0),\r\n 0,\r\n );\r\n const subtotal = withRank + fromGear;\r\n const total = subtotal * (1 + percent / 100);\r\n return {\r\n base,\r\n fromLevel: withLevel - base,\r\n fromRank: withRank - withLevel,\r\n fromGear,\r\n percent,\r\n total,\r\n };\r\n}\r\n\r\n/**\r\n * Slots \"Take off all\" asks the server to empty (Unity EquipmentPanel.CollectUnequipSlotIDs): every\r\n * slot the hero's config declares, plus every slot the local data says is worn — by the hero's own\r\n * map AND by the instances that remember being worn by this hero. The server empties only what IS\r\n * worn on its side and skips the rest, so stale local data stops mattering. The worn skin (\"@skin\")\r\n * is not gear and stays on.\r\n */\r\nexport function unequipAllSlots(\r\n heroID: string,\r\n slots: SlotView[],\r\n current: CharacterModelView[\"Equipment\"],\r\n pieces: GearPiece[],\r\n): string[] {\r\n const out = new Set<string>();\r\n for (const s of slots) out.add(s.id);\r\n for (const id of Object.keys(current ?? {})) out.add(id);\r\n for (const p of pieces)\r\n if (p.equippedBy?.characterID === heroID) out.add(p.equippedBy.slotID);\r\n return [...out].filter((id) => id && !id.startsWith(\"@\"));\r\n}\r\n\r\n// ── Badge ────────────────────────────────────────────────────────────────────────────────────\r\n\r\n/** Badge of the heroes screen: heroes that can be unlocked or ranked up right now, plus better gear. */\r\nexport function heroesBadge(\r\n entries: RosterEntry[],\r\n canPay: (lines: ResourceLine[]) => boolean,\r\n ladderOf: (e: RosterEntry) => RankLadderView | null,\r\n betterGear: number,\r\n): number {\r\n let n = 0;\r\n for (const e of entries) {\r\n if (\r\n e.state === \"locked\" &&\r\n e.unlockPrice.length > 0 &&\r\n canPay(e.unlockPrice)\r\n )\r\n n++;\r\n if (e.state !== \"locked\") {\r\n const cost = rankCost(ladderOf(e), e.rank);\r\n if (cost && cost.length > 0 && canPay(cost)) n++;\r\n }\r\n }\r\n return n + (betterGear > 0 ? 1 : 0);\r\n}\r\n\r\n/** Whether the title has heroes at all — until it does, the lobby hides the Heroes tab. */\r\nexport function hasTitleData(section: CharacterSection | undefined): boolean {\r\n return Object.keys(section?.Definitions ?? {}).length > 0;\r\n}\r\n"
|
|
64
82
|
},
|
|
65
83
|
{
|
|
66
84
|
"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 canAfford,\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport { makeCharacterScreen } from \"./CharacterScreen\";\nimport {\n bestLoadout,\n gearPieces,\n hasTitleData,\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 characterModule: Module = defineModule({\n id: \"character\",\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 available: false,\n Screen: makeCharacterScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"character\",\n hasTitleData(configSection<CharacterSection>(ctx.client, \"Character\")),\n \"no heroes on the title (Character definitions are empty)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"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 // Heroes and gear come with the login state; the central cache asks the server only for what\n // is not there yet (a hero given by default).\n update();\n void client.cache.ensureState([\"Character\"]).then(update);\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
85
|
+
"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 hasTitleData,\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\";\nimport { heroSelected } from \"./events\";\nimport { readSelected, resolveSelected } from \"./selection\";\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\". The hero the player\n// plays with goes out as `character:hero-selected@1` — once the roster is known, and on every pick.\nexport const characterModule: Module = defineModule({\n id: \"character\",\n meta: { name: \"Heroes\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n let announced: string | null = null;\n const announce = (characterId: string | undefined) => {\n if (!characterId || characterId === announced) return;\n announced = characterId;\n ctx.events.emit(heroSelected, { characterId });\n };\n\n ctx.features.register({\n id: \"character\",\n label: t(\"title\"),\n icon: \"hero\",\n group: \"economy\",\n order: 20,\n primary: true,\n available: false,\n Screen: makeCharacterScreen(ctx.features, announce),\n });\n ctx.features.setAvailable(\n \"character\",\n hasTitleData(configSection<CharacterSection>(ctx.client, \"Character\")),\n \"no heroes on the title (Character definitions are empty)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features, announce),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"character\", selected: announced }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(\n features: FeatureRegistry,\n announce: (characterId: string | undefined) => void,\n) {\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 // The saved hero (or the first playable one) as soon as the roster is known — the game\n // fields it from the first frame. Re-resolved on every update: a hero that just became\n // playable may now be the answer.\n announce(resolveSelected(list, readSelected())?.id);\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 // Heroes and gear come with the login state; the central cache asks the server only for what\n // is not there yet (a hero given by default).\n update();\n void client.cache.ensureState([\"Character\"]).then(update);\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"path": "selection.ts",
|
|
89
|
+
"content": "import type { RosterEntry } from \"./model\";\n\n// The selected hero (Unity: CharactersSave.SelectedCharacterId). A per-device convenience, like the\n// Unity save it mirrors: the server does not know which hero is \"selected\" — every action names its\n// hero explicitly.\n\nconst SELECTED_KEY = \"idos-heroes-selected\";\n\nexport function readSelected(): string | null {\n try {\n return localStorage.getItem(SELECTED_KEY);\n } catch {\n return null;\n }\n}\n\nexport function saveSelected(id: string): void {\n try {\n localStorage.setItem(SELECTED_KEY, id);\n } catch {\n // storage blocked: the choice lives for this session\n }\n}\n\n/** The saved hero while it is playable, else the first playable one, else the first at all. */\nexport function resolveSelected(\n list: RosterEntry[],\n savedID: string | null,\n): RosterEntry | undefined {\n return (\n list.find((h) => h.id === savedID && h.state !== \"locked\") ??\n list.find((h) => h.state !== \"locked\") ??\n list[0]\n );\n}\n"
|
|
68
90
|
},
|
|
69
91
|
{
|
|
70
92
|
"path": "module.meta.json",
|
|
71
|
-
"content": "{\n \"id\": \"character\",\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.1\"\n}\n"
|
|
93
|
+
"content": "{\n \"id\": \"character\",\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, 'Take off all', 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, 'Upgrade all' — as many levels as the balances cover, cheapest first, in one batch —, 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. Emits character:hero-selected@1 with the hero the player plays with (on start-up and on every pick) so a game can field that hero.\",\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 \"upgrade all stats in one batch\",\n \"stat breakdown popup by source\",\n \"equipment slots and equip / take off\",\n \"equip best gear automatically\",\n \"take off all gear\",\n \"item level upgrades\",\n \"character skins\",\n \"demo heroes\",\n \"selected hero for the game (event)\"\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.2\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"when\": \"on start-up (the saved or first playable hero, once the roster is known) and whenever the player selects another hero on the Heroes screen\",\n \"payload\": {\n \"characterId\": \"string\"\n }\n }\n ]\n }\n}\n"
|
|
72
94
|
}
|
|
73
95
|
]
|
|
74
96
|
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.0",
|
|
39
39
|
"@idosgames/module-sdk": "0.8.2",
|
|
40
|
-
"@idosgames/react": "0.4.
|
|
40
|
+
"@idosgames/react": "0.4.4",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "1bd9bec3b0ee8548e800e51bf045b0215da4320231fbf8565350554558f846e4",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@idosgames/core": "0.21.0",
|
|
38
38
|
"@idosgames/module-sdk": "0.8.2",
|
|
39
|
-
"@idosgames/react": "0.4.
|
|
39
|
+
"@idosgames/react": "0.4.4",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "04ecd67d6caa7dedcaa260db3f589667b291335c6dd5f5a57797204841c3396d",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@idosgames/core": "0.21.0",
|
|
36
36
|
"@idosgames/module-sdk": "0.8.2",
|
|
37
|
-
"@idosgames/react": "0.4.
|
|
37
|
+
"@idosgames/react": "0.4.4",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
40
|
"contentHash": "ab28150d10234d119e4c757f55cc4fa9dbf6760ef75c8c6933538272d8f69669",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.0",
|
|
39
39
|
"@idosgames/module-sdk": "0.8.2",
|
|
40
|
-
"@idosgames/react": "0.4.
|
|
40
|
+
"@idosgames/react": "0.4.4",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "6c4adf3a2a0ac70e26cc90e3c7996d982d869a7fd1583439e6409db1fe7063d5",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@idosgames/core": "0.21.0",
|
|
38
38
|
"@idosgames/module-sdk": "0.8.2",
|
|
39
|
-
"@idosgames/react": "0.4.
|
|
39
|
+
"@idosgames/react": "0.4.4",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
42
|
"contentHash": "34c7958b619d72ec381217931e4cfcd97981d88eebed82ca84450212a73ddabb",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@idosgames/core": "0.21.0",
|
|
41
41
|
"@idosgames/module-sdk": "0.8.2",
|
|
42
|
-
"@idosgames/react": "0.4.
|
|
42
|
+
"@idosgames/react": "0.4.4",
|
|
43
43
|
"react": "19.2.7"
|
|
44
44
|
},
|
|
45
45
|
"contentHash": "ba7d13114443f6b3d80101b158f287917cb2fc712dd66b9d98c462b9a6fb2c98",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@idosgames/core": "0.21.0",
|
|
39
39
|
"@idosgames/module-sdk": "0.8.2",
|
|
40
|
-
"@idosgames/react": "0.4.
|
|
40
|
+
"@idosgames/react": "0.4.4",
|
|
41
41
|
"react": "19.2.7"
|
|
42
42
|
},
|
|
43
43
|
"contentHash": "baae3cd9af04f1f2286605348c8eed3570add2eec30357f0b0188d32a63cbe3d",
|