Skip to content

Commit

Permalink
Added Update Recommendations
Browse files Browse the repository at this point in the history
  • Loading branch information
Aragas committed Apr 30, 2024
1 parent 67c16d1 commit cd6e2c0
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 26 deletions.
4 changes: 2 additions & 2 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export class BannerlordGame implements types.IGame {
}

public queryPath = (): Bluebird<string | types.IGameStoreEntry> => {
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<string> => {
return this._launcherManager.getGameVersionVortex();
return this._launcherManager.getGameVersionVortexAsync();
};
public executable = (discoveredPath?: string): string => {
return getBannerlordMainExe(discoveredPath, this._api);
Expand Down
30 changes: 16 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,6 +17,7 @@ import {
didDeployBLSE,
didPurgeBLSE,
addedFiles,
ModAnalyzerProxy,
} from './utils';
import { SaveList, SavePageOptions, Settings } from './views';
import { IAddedFiles } from './types';
Expand Down Expand Up @@ -116,18 +116,20 @@ const main = (context: types.IExtensionContext): boolean => {
toBluebird<void>(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)
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { GAME_ID } from './common';

export type RequiredProperties<T, P extends keyof T> = Omit<T, P> & Required<Pick<T, P>>;

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;
Expand Down
1 change: 1 addition & 0 deletions src/utils/butr/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './modAnalyzerProxy';
70 changes: 70 additions & 0 deletions src/utils/butr/modAnalyzerProxy.ts
Original file line number Diff line number Diff line change
@@ -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<IModAnalyzerResult> {
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();
});
}
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './blse';
export * from './butr';
export * from './loadOrder';
export * from './module';
export * from './events';
Expand Down
39 changes: 35 additions & 4 deletions src/utils/loadOrder/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<BannerlordItemRenderer
api={api}
manager={manager}
item={item}
className={className}
key={item.loEntry.id}
availableProviders={availableProviders}
compatibilityInfo={compatibilityScore}
/>
);
};
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<IModAnalyzerRequestModule>((x) => ({
moduleId: x.id,
moduleVersion: versionToString(x.version),
})),
};
proxy.analyze(query).then((result) => {
this._compatibilityScores = result.modules.reduce<IModuleCompatibilityInfoCache>((map, curr) => {
map[curr.moduleId] = {
score: curr.compatibility,
recommendedScore: curr.recommendedCompatibility,
recommendedVersion: curr.recommendedModuleVersion,
};
return map;
}, {});
this.forceRefresh();
});
};
}

private forceRefresh = (): void => {
Expand Down
9 changes: 8 additions & 1 deletion src/utils/vortexLauncherManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,17 @@ export class VortexLauncherManager {
/**
* A simple wrapper for Vortex that returns a promise
*/
public getGameVersionVortex = (): Promise<string> => {
public getGameVersionVortexAsync = (): Promise<string> => {
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
*/
Expand Down
12 changes: 10 additions & 2 deletions src/views/LoadOrderInfoPanel/LoadOrderInfoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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';

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 (
<>
<p>
<tooltip.Button tooltip={hint} onClick={props.refresh}>
{t('Update Compatibility Score')}
</tooltip.Button>
</p>
<p>
{t(
`You can adjust the load order for Bannerlord by dragging and dropping mods up or down on this page. ` +
Expand Down
47 changes: 44 additions & 3 deletions src/views/LoadOrderItemRenderer/LoadOrderItemRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -73,6 +75,7 @@ export function BannerlordItemRenderer(props: IBaseProps): JSX.Element {
{renderModuleIcon(item.loEntry)}
<p className="load-order-name">{name} ({version})</p>
{renderExternalBanner(item.loEntry)}
{renderCompatibilityInfo(props)}
{renderModuleDuplicates(props, item.loEntry)}
{renderModuleProviderIcon(item.loEntry)}
{checkBox()}
Expand Down Expand Up @@ -124,7 +127,12 @@ function renderValidationError(props: IBaseProps): JSX.Element | null {
? invalidEntries.find((inv) => inv.id.toLowerCase() === loEntry.id.toLowerCase())
: undefined;
return invalidEntry !== undefined ? (
<tooltip.Icon className="fblo-invalid-entry" name="feedback-error" tooltip={invalidEntry.reason} />
<tooltip.Icon
className="fblo-invalid-entry"
name="feedback-error"
style={{ width: `1.5em`, height: `1.5em`, }}
tooltip={invalidEntry.reason}>
</tooltip.Icon>
) : null;
}

Expand Down Expand Up @@ -178,6 +186,39 @@ function renderModuleDuplicates(props: IBaseProps, item: types.IFBLOLoadOrderEnt
return <div style={{ width: `1.5em`, height: `1.5em`, }} />;
}

function renderCompatibilityInfo(props: IBaseProps): JSX.Element {
const { compatibilityInfo: compatibilityScore, item, manager } = props;

if (compatibilityScore === undefined) {
return <div style={{ width: `1.5em`, height: `1.5em`, }} />;
}

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 (
<tooltip.Icon
style={{color: color, width: `1.5em`, height: `1.5em`}}
name="feedback-error"
tooltip={hint}>
</tooltip.Icon>
);
}

function isLocked(item: types.IFBLOLoadOrderEntry<IVortexViewModelData>): boolean {
return [true, 'true', 'always'].includes(item.locked as types.FBLOLockState);
}
Expand Down

0 comments on commit cd6e2c0

Please sign in to comment.