From cd6e2c0e78532e3acdb8c8bcf10f77ca1d75f16b Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 1 May 2024 00:49:15 +0300 Subject: [PATCH] Added Update Recommendations --- src/game.ts | 4 +- src/index.ts | 30 ++++---- src/types.ts | 10 +++ src/utils/butr/index.ts | 1 + src/utils/butr/modAnalyzerProxy.ts | 70 +++++++++++++++++++ src/utils/index.ts | 1 + src/utils/loadOrder/manager.tsx | 39 +++++++++-- src/utils/vortexLauncherManager.ts | 9 ++- .../LoadOrderInfoPanel/LoadOrderInfoPanel.tsx | 12 +++- .../LoadOrderItemRenderer.tsx | 47 ++++++++++++- 10 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 src/utils/butr/index.ts create mode 100644 src/utils/butr/modAnalyzerProxy.ts diff --git a/src/game.ts b/src/game.ts index 426cee7..fed9e86 100644 --- a/src/game.ts +++ b/src/game.ts @@ -35,13 +35,13 @@ export class BannerlordGame implements types.IGame { } public queryPath = (): Bluebird => { - return toBluebird(findGame)().then((game) => game.gamePath); + return toBluebird(findGame)({}).then((game) => game.gamePath); }; public queryModPath = (_gamePath: string): string => { return `.`; }; public getGameVersion = (_gamePath: string, _exePath: string): PromiseLike => { - return this._launcherManager.getGameVersionVortex(); + return this._launcherManager.getGameVersionVortexAsync(); }; public executable = (discoveredPath?: string): string => { return getBannerlordMainExe(discoveredPath, this._api); diff --git a/src/index.ts b/src/index.ts index 144acd5..075b6c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ -/* eslint-disable prettier/prettier */ // eslint-disable-next-line no-restricted-imports import Bluebird, { Promise, method as toBluebird } from 'bluebird'; import path from 'path'; -import { actions, selectors, types, util } from 'vortex-api'; +import { actions, selectors, types, util, log } from 'vortex-api'; import { setCurrentSave, setSortOnDeploy } from './actions'; import { GAME_ID } from './common'; import { BannerlordGame } from './game'; @@ -18,6 +17,7 @@ import { didDeployBLSE, didPurgeBLSE, addedFiles, + ModAnalyzerProxy, } from './utils'; import { SaveList, SavePageOptions, Settings } from './views'; import { IAddedFiles } from './types'; @@ -116,18 +116,20 @@ const main = (context: types.IExtensionContext): boolean => { toBluebird(async () => { context.api.setStylesheet('savegame', path.join(__dirname, 'savegame.scss')); - /* TODO: Provide compatibility info for Game Version -> Mod Version from the BUTR Site - const proxy = new BUTRProxy(context.api); - context.api.addMetaServer(`butr.site`, { - url: '', - loopbackCB: (query: types.IQuery) => Bluebird.resolve(proxy.find(query)).catch(err => { - log('error', 'failed to look up smapi meta info', err.message); - return Bluebird.resolve([]); - }), - cacheDurationSec: 86400, - priority: 25, - }); - */ + /* + // TODO: Provide compatibility info for Game Version -> Mod Version from the BUTR Site + const proxy = new ModAnalyzerProxy(context.api); + context.api.addMetaServer(`butr.link`, { + url: '', + loopbackCB: (query: types.IQuery) => + Bluebird.resolve(proxy.find(query)).catch((err) => { + log('error', 'failed to look up butr meta info', err.message); + return Bluebird.resolve([]); + }), + cacheDurationSec: 86400, + priority: 25, + }); + */ context.api.onAsync(`added-files`, (profileId: string, files: IAddedFiles[]) => addedFiles(context.api, profileId, files) diff --git a/src/types.ts b/src/types.ts index 735e64e..1532222 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,16 @@ import { GAME_ID } from './common'; export type RequiredProperties = Omit & Required>; +export interface IModuleCompatibilityInfoCache { + [moduleId: string]: IModuleCompatibilityInfo; +} + +export interface IModuleCompatibilityInfo { + score: number; + recommendedScore?: number | undefined; + recommendedVersion?: string | undefined; +} + export type PersistenceLoadOrderStorage = IPersistenceLoadOrderEntry[]; export interface IPersistenceLoadOrderEntry { id: string; diff --git a/src/utils/butr/index.ts b/src/utils/butr/index.ts new file mode 100644 index 0000000..719321b --- /dev/null +++ b/src/utils/butr/index.ts @@ -0,0 +1 @@ +export * from './modAnalyzerProxy'; diff --git a/src/utils/butr/modAnalyzerProxy.ts b/src/utils/butr/modAnalyzerProxy.ts new file mode 100644 index 0000000..8be04b1 --- /dev/null +++ b/src/utils/butr/modAnalyzerProxy.ts @@ -0,0 +1,70 @@ +import * as https from 'https'; +import { log, types } from 'vortex-api'; + +export interface IModAnalyzerRequestModule { + moduleId: string; + moduleVersion?: string; +} + +export interface IModAnalyzerRequestQuery { + gameVersion: string; + modules: IModAnalyzerRequestModule[]; +} + +export interface IModAnalyzerResultModule { + moduleId: string; + compatibility: number; + recommendedCompatibility: number | undefined; + recommendedModuleVersion?: string | undefined; +} + +export interface IModAnalyzerResult { + modules: IModAnalyzerResultModule[]; +} + +const BUTR_HOST = 'sitenexusmods.butr.link'; + +export class ModAnalyzerProxy { + private mAPI: types.IExtensionApi; + private mOptions: https.RequestOptions; + constructor(api: types.IExtensionApi) { + this.mAPI = api; + this.mOptions = { + host: BUTR_HOST, + method: 'POST', + protocol: 'https:', + path: '/api/v1/ModsAnalyzer/GetCompatibilityScore', + headers: { + Tenant: '1', // Bannerlord + 'Content-Type': 'application/json', + }, + }; + } + + public async analyze(query: IModAnalyzerRequestQuery): Promise { + return new Promise((resolve, reject) => { + const req = https + .request(this.mOptions, (res) => { + let body = Buffer.from([]); + res + .on('error', (err) => reject(err)) + .on('data', (chunk) => { + body = Buffer.concat([body, chunk]); + }) + .on('end', () => { + const textual = body.toString('utf8'); + try { + const parsed = JSON.parse(textual); + resolve(parsed); + } catch (err) { + log('error', 'failed to parse butr mod analyzer response', textual); + reject(err); + } + }); + }) + .on('error', (err) => reject(err)); + req.write(JSON.stringify(query)); + req.end(); + }); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 2f2942f..86f6bc6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './blse'; +export * from './butr'; export * from './loadOrder'; export * from './module'; export * from './events'; diff --git a/src/utils/loadOrder/manager.tsx b/src/utils/loadOrder/manager.tsx index dac6e4f..22c535e 100644 --- a/src/utils/loadOrder/manager.tsx +++ b/src/utils/loadOrder/manager.tsx @@ -3,16 +3,23 @@ import { types, selectors } from 'vortex-api'; import { IInvalidResult } from 'vortex-api/lib/extensions/file_based_loadorder/types/types'; import { BannerlordModuleManager, Utils, types as vetypes } from '@butr/vortexextensionnative'; import { vortexToLibrary, libraryToVortex, libraryVMToVortex, libraryVMToLibrary } from '.'; -import { VortexLauncherManager } from '../'; +import { + IModAnalyzerRequestModule, + IModAnalyzerRequestQuery, + ModAnalyzerProxy, + VortexLauncherManager, + versionToString, +} from '../'; import { GAME_ID } from '../../common'; import { LoadOrderInfoPanel, BannerlordItemRenderer } from '../../views'; -import { RequiredProperties, VortexLoadOrderStorage } from '../../types'; +import { IModuleCompatibilityInfoCache, RequiredProperties, VortexLoadOrderStorage } from '../../types'; -export class LoadOrderManager implements types.IFBLOGameInfo { +export class LoadOrderManager implements types.ILoadOrderGameInfo { private _api: types.IExtensionApi; private _manager: VortexLauncherManager; private _isInitialized = false; private _allModules: vetypes.ModuleInfoExtendedWithMetadata[] = []; + private _compatibilityScores: IModuleCompatibilityInfoCache = {}; public gameId: string = GAME_ID; public toggleableEntries = true; @@ -33,18 +40,42 @@ export class LoadOrderManager implements types.IFBLOGameInfo { const availableProviders = this._allModules .filter((x) => x.id === item.loEntry.id) .map((x) => x.moduleProviderType); + const compatibilityScore = this._compatibilityScores[item.loEntry.id]; return ( ); }; - const refresh = () => this.forceRefresh(); + const refresh = () => { + const proxy = new ModAnalyzerProxy(this._api); + const gameVersion = this._manager.getGameVersionVortex(); + const query: IModAnalyzerRequestQuery = { + gameVersion: gameVersion, + modules: this._allModules.map((x) => ({ + moduleId: x.id, + moduleVersion: versionToString(x.version), + })), + }; + proxy.analyze(query).then((result) => { + this._compatibilityScores = result.modules.reduce((map, curr) => { + map[curr.moduleId] = { + score: curr.compatibility, + recommendedScore: curr.recommendedCompatibility, + recommendedVersion: curr.recommendedModuleVersion, + }; + return map; + }, {}); + this.forceRefresh(); + }); + }; } private forceRefresh = (): void => { diff --git a/src/utils/vortexLauncherManager.ts b/src/utils/vortexLauncherManager.ts index b967dd0..c3f97f3 100644 --- a/src/utils/vortexLauncherManager.ts +++ b/src/utils/vortexLauncherManager.ts @@ -146,10 +146,17 @@ export class VortexLauncherManager { /** * A simple wrapper for Vortex that returns a promise */ - public getGameVersionVortex = (): Promise => { + public getGameVersionVortexAsync = (): Promise => { return Promise.resolve(this._launcherManager.getGameVersion()); }; + /** + * A simple wrapper for Vortex that returns a promise + */ + public getGameVersionVortex = (): string => { + return this._launcherManager.getGameVersion(); + }; + /** * Calls LauncherManager's testModule and converts the result to Vortex data */ diff --git a/src/views/LoadOrderInfoPanel/LoadOrderInfoPanel.tsx b/src/views/LoadOrderInfoPanel/LoadOrderInfoPanel.tsx index 3944316..5cdf9f8 100644 --- a/src/views/LoadOrderInfoPanel/LoadOrderInfoPanel.tsx +++ b/src/views/LoadOrderInfoPanel/LoadOrderInfoPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { util } from 'vortex-api'; +import { util, tooltip } from 'vortex-api'; import { useTranslation } from 'react-i18next'; import { I18N_NAMESPACE } from '../../common'; @@ -7,13 +7,21 @@ interface IBaseProps { refresh: () => void; } -export function LoadOrderInfoPanel(_props: IBaseProps) { +export function LoadOrderInfoPanel(props: IBaseProps) { const [t] = useTranslation(I18N_NAMESPACE); const openWiki = React.useCallback(() => { util.opn(`https://wiki.nexusmods.com/index.php/Modding_Bannerlord_with_Vortex`).catch(() => null); }, []); + // TODO: Take from BLSE translation + const NL = '\n'; + const hint = `Get Update Recommendations${NL}Clicking on this button will send your module list to the BUTR server to get compatibility scores and recommended versions.${NL}They are based on the crash reports from ButterLib.${NL}${NL}(Requires Internet Connection)`; return ( <> +

+ + {t('Update Compatibility Score')} + +

{t( `You can adjust the load order for Bannerlord by dragging and dropping mods up or down on this page. ` + diff --git a/src/views/LoadOrderItemRenderer/LoadOrderItemRenderer.tsx b/src/views/LoadOrderItemRenderer/LoadOrderItemRenderer.tsx index b0ab396..81db0d5 100644 --- a/src/views/LoadOrderItemRenderer/LoadOrderItemRenderer.tsx +++ b/src/views/LoadOrderItemRenderer/LoadOrderItemRenderer.tsx @@ -5,17 +5,19 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { actions, Icon, selectors, tooltip, types, util } from 'vortex-api'; -import { IVortexViewModelData } from '../../types'; -import { versionToString } from '../../utils'; +import { IModuleCompatibilityInfo, IVortexViewModelData } from '../../types'; +import { versionToString, VortexLauncherManager } from '../../utils'; import { MODULE_LOGO, STEAM_LOGO, TW_LOGO } from '../../common'; import { types as vetypes, Utils } from '@butr/vortexextensionnative'; import { TooltipImage } from '../Controls'; interface IBaseProps { api: types.IExtensionApi; + manager: VortexLauncherManager; className?: string; item: types.IFBLOItemRendererProps; availableProviders: vetypes.ModuleProviderType[]; + compatibilityInfo: IModuleCompatibilityInfo | undefined; } interface IConnectedProps { @@ -73,6 +75,7 @@ export function BannerlordItemRenderer(props: IBaseProps): JSX.Element { {renderModuleIcon(item.loEntry)}

{name} ({version})

{renderExternalBanner(item.loEntry)} + {renderCompatibilityInfo(props)} {renderModuleDuplicates(props, item.loEntry)} {renderModuleProviderIcon(item.loEntry)} {checkBox()} @@ -124,7 +127,12 @@ function renderValidationError(props: IBaseProps): JSX.Element | null { ? invalidEntries.find((inv) => inv.id.toLowerCase() === loEntry.id.toLowerCase()) : undefined; return invalidEntry !== undefined ? ( - + + ) : null; } @@ -178,6 +186,39 @@ function renderModuleDuplicates(props: IBaseProps, item: types.IFBLOLoadOrderEnt return
; } +function renderCompatibilityInfo(props: IBaseProps): JSX.Element { + const { compatibilityInfo: compatibilityScore, item, manager } = props; + + if (compatibilityScore === undefined) { + return
; + } + + const hasRecommendation = compatibilityScore.recommendedVersion !== undefined && compatibilityScore.recommendedVersion !== null; + + // TODO: Take from BLSE translation + const NL = '\n'; + const SCORE = compatibilityScore.score; + const CURRENTVERSION = versionToString(item.loEntry.data?.moduleInfoExtended.version); + const RECOMMENDEDSCORE = compatibilityScore.recommendedScore ?? 0; + const RECOMMENDEDVERSION = compatibilityScore.recommendedVersion ?? ''; + const GAMEVERSION = manager.getGameVersionVortex(); + const hint = hasRecommendation + ? `Based on BUTR analytics:${NL}${NL}Compatibility Score ${SCORE}%${NL}${NL}Suggesting to update to ${RECOMMENDEDVERSION}.${NL}Compatibility Score ${RECOMMENDEDSCORE}%${NL}${NL}${RECOMMENDEDVERSION} has a better compatibility for game ${GAMEVERSION} rather than ${CURRENTVERSION}!` + : `Based on BUTR analytics:${NL}${NL}Update is not required.${NL}Compatibility Score ${SCORE}%${NL}${NL}${CURRENTVERSION} is one of the best version for game ${GAMEVERSION}`; + + const color = compatibilityScore.score >= 75 + ? 'green' : compatibilityScore.score >= 50 + ? 'yellow' : 'red'; + + return ( + + + ); +} + function isLocked(item: types.IFBLOLoadOrderEntry): boolean { return [true, 'true', 'always'].includes(item.locked as types.FBLOLockState); }