Skip to content

Commit

Permalink
fixed intermittent loadorder reset when installing a mod
Browse files Browse the repository at this point in the history
- update set event handlers have been refactored
- added ability to import/export loadorder to all FBLO game extensions
  • Loading branch information
IDCs committed Dec 17, 2024
1 parent a9e85b0 commit a5991de
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 41 deletions.
46 changes: 15 additions & 31 deletions src/extensions/file_based_loadorder/UpdateSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export default class UpdateSet extends Set<number> {
super([]);
this.mApi = api;
this.mIsFBLO = isFBLO;
this.registerListeners();
}

public addNumericModId = (lo: ILoadOrderEntryExt) => {
Expand Down Expand Up @@ -79,19 +78,6 @@ export default class UpdateSet extends Set<number> {
return filtered;
};

private registerListeners = () => {
this.mApi.events.on('gamemode-activated', this.init);
}

private removeListeners = () => {
this.mApi.events.removeListener('gamemode-activated', this.init);
}

public destroy = () => {
this.removeListeners();
this.reset();
}

private reset = () => {
super.clear();
this.mModEntries = [];
Expand Down Expand Up @@ -131,24 +117,22 @@ export default class UpdateSet extends Set<number> {
return loadOrder;
}
const restoredLO: ILoadOrderEntry[] = [...loadOrder];
loadOrder.forEach((iter, idx) => {
// Check if the updateSet has this modId.
const stored: { numId: number, entries: ILoadOrderEntryExt[] } = this.findEntry(iter);
if (stored) {
// We're only interested in 1 specific entry, keep in mind that there might be multiple lo entries
// that are associated with the same numeric mod id.
const entryExt: ILoadOrderEntryExt = stored.entries.find(l => l.name === iter.name);
if (entryExt && entryExt.index !== idx) {
// The entry is in the wrong position - re-arrange the array.
restoredLO.splice(idx, 1);
restoredLO.splice(entryExt.index, 0, iter);

// We only remove the numeric mod id if we confirm that we modified the
// list, otherwise we keep it around as the restoration functionality
// can be called multiple times without modification.
this.tryRemoveNumId(stored.numId, stored.entries, iter.name);
}
const getEntryExt = (entry: ILoadOrderEntry): ILoadOrderEntryExt | null => {
const stored = this.findEntry(entry);
if (!stored) {
// This is probably an entry for a manually added mod/native game entry
// use the existing index.
return { ...entry, index: loadOrder.findIndex(l => l.name === entry.name) };
}
return stored.entries.find(l => l.name === entry.name) || null;
}
restoredLO.sort((lhs, rhs) => {
const lhsEntry = getEntryExt(lhs);
const rhsEntry = getEntryExt(rhs);
if (!lhsEntry || !rhsEntry) {
return 0;
}
return lhsEntry.index - rhsEntry.index;
});
return restoredLO;
}
Expand Down
69 changes: 68 additions & 1 deletion src/extensions/file_based_loadorder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import * as _ from 'lodash';

import { setValidationResult } from './actions/session';
import * as path from 'path';

import { setFBForceUpdate, setValidationResult } from './actions/session';

import { IExtensionContext } from '../../types/IExtensionContext';
import {
Expand Down Expand Up @@ -31,6 +33,8 @@ import { setFBLoadOrderRedundancy } from './actions/session';
import { addGameEntry, findGameEntry } from './gameSupport';
import { assertValidationResult, errorHandler } from './util';

import * as fs from '../../util/fs';

import UpdateSet, { ILoadOrderEntryExt } from './UpdateSet';

interface IDeployment {
Expand Down Expand Up @@ -219,6 +223,11 @@ async function applyNewLoadOrder(api: types.IExtensionApi,
await gameEntry.serializeLoadOrder(newLO, prev);
} catch (err) {
return errorHandler(api, gameEntry.gameId, err);
} finally {
// After serialization (even when failed), depending on the game extension,
// we may need to force an update as the serialization function may have
// changed the load order in some way.
api.store.dispatch(setFBForceUpdate(profile.id));
}

return;
Expand Down Expand Up @@ -264,6 +273,15 @@ export default function init(context: IExtensionContext) {
context.registerReducer(['persistent', 'loadOrder'], modLoadOrderReducer);
context.registerReducer(['session', 'fblo'], sessionReducer);

const setOrder = async (profileId: string, loadOrder: types.LoadOrder, refresh?: boolean) => {
const profile = selectors.profileById(context.api.getState(), profileId);
if (!refresh) {
// Anything that isn't a refresh is a user action.
// The Update set has to be re-initialized with the new load order.
updateSet.init(profile.gameId, loadOrder.map((lo, idx) => ({ ...lo, index: idx })));
}
context.api.store.dispatch(setFBLoadOrder(profileId, loadOrder));
}
context.registerMainPage('sort-none', 'Load Order', FileBasedLoadOrderPage, {
id: 'file-based-loadorder',
hotkey: 'E',
Expand All @@ -277,8 +295,46 @@ export default function init(context: IExtensionContext) {
props: () => {
return {
getGameEntry: findGameEntry,
onImportList: async () => {
const api = context.api;
const file = await api.selectFile({ filters: [{ name: 'JSON', extensions: ['json'] }], title: 'Import Load Order' });
if (!file) {
return;
}
try {
const fileData = await fs.readFileAsync(file, { encoding: 'utf8' });
const loData: LoadOrder = JSON.parse(fileData);
if (!Array.isArray(loData)) {
throw new Error('invalid load order data');
}
updateSet.init(selectors.activeGameId(api.getState()), loData.map((lo, idx) => ({ ...lo, index: idx })));
const profileId = selectors.activeProfile(api.getState()).id;
context.api.store.dispatch(setFBLoadOrder(profileId, loData));
api.sendNotification({ type: 'success', message: 'Load order imported', id: 'import-load-order' });
} catch (err) {
api.showErrorNotification('Failed to import load order', err, { allowReport: false });
}
},
onExportList: async () => {
const api = context.api;
const state = api.getState();
const profileId = selectors.activeProfile(state).id;
const loadOrder = util.getSafe(state, ['persistent', 'loadOrder', profileId], []);
const data = JSON.stringify(loadOrder, null, 2);
const loPath = await api.saveFile({ defaultPath: 'loadorder.json', filters: [{ name: 'JSON', extensions: ['json'] }], title: 'Export Load Order' });
if (loPath) {
try {
await fs.ensureDirWritableAsync(path.basename(loPath));
await fs.writeFileAsync(loPath, data);
api.sendNotification({ type: 'success', message: 'Load order exported', id: 'export-load-order' });
} catch (err) {
api.showErrorNotification('Failed to export load order', err, { allowReport: false });
}
}
},
validateLoadOrder: (profile: types.IProfile, loadOrder: LoadOrder) =>
validateLoadOrder(context.api, profile, loadOrder),
onSetOrder: setOrder,
onStartUp: (gameId: string) => onStartUp(context.api, gameId),
onShowError: (gameId: string, error: Error) => errorHandler(context.api, gameId, error),
};
Expand Down Expand Up @@ -325,6 +381,8 @@ export default function init(context: IExtensionContext) {
context.api.onStateChange(['persistent', 'profiles'],
(prev, current) => genProfilesChange(context.api, prev, current));

context.api.events.on('gamemode-activated', (gameId: string) => onGameModeActivated(context.api, gameId));

context.api.onAsync('did-deploy', genDidDeploy(context.api));
context.api.onAsync('will-purge', genWillPurge(context.api));
context.api.onAsync('did-purge', genDidPurge(context.api));
Expand All @@ -339,6 +397,15 @@ export default function init(context: IExtensionContext) {
return true;
}

async function onGameModeActivated(api: types.IExtensionApi, gameId: string) {
const gameEntry: ILoadOrderGameInfo = findGameEntry(gameId);
if (gameEntry === undefined) {
// Game does not require LO.
return;
}
updateSet.init(gameId);
}

async function onWillRemoveMods(api: types.IExtensionApi,
gameId: string,
modIds: string[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as _ from 'lodash';
import * as React from 'react';
import { Panel } from 'react-bootstrap';
import { withTranslation } from 'react-i18next';
import { WithTranslation, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';

import * as actions from '../../../actions/index';
Expand All @@ -20,6 +20,7 @@ import { IItemRendererProps, ILoadOrderGameInfo, LoadOrder,
import InfoPanel from './InfoPanel';
import ItemRenderer from './ItemRenderer';
import { setFBForceUpdate } from '../actions/session';
import ToolbarDropdown from '../../../controls/ToolbarDropdown';

const PanelX: any = Panel;

Expand All @@ -33,6 +34,9 @@ interface IBaseState {

export interface IBaseProps {
getGameEntry: (gameId: string) => ILoadOrderGameInfo;
onImportList: () => void;
onExportList: () => void;
onSetOrder: (profileId: string, loadOrder: LoadOrder, refresh?: boolean) => void;
onStartUp: (gameMode: string) => Promise<LoadOrder>;
onShowError: (gameId: string, error: Error) => void;
validateLoadOrder: (profile: types.IProfile, newLO: LoadOrder) => Promise<void>;
Expand Down Expand Up @@ -60,7 +64,6 @@ interface IConnectedProps {

interface IActionProps {
onSetDeploymentNecessary: (gameId: string, necessary: boolean) => void;
onSetOrder: (profileId: string, loadOrder: LoadOrder) => void;
onForceRefresh: (profileId: string) => void;
}

Expand Down Expand Up @@ -125,13 +128,34 @@ class FileBasedLoadOrderPage extends ComponentEx<IProps, IComponentState> {
onClick: this.onRefreshList,
};
},
},
}, {
component: ToolbarDropdown,
props: () => {
return {
t: this.props.t,
key: 'btn-import-export-list',
id: 'btn-import-export-list',
instanceId: [],
icons: [
{
icon: (this.state.updating || this.props.disabled) ? 'spinner' : 'import',
title: 'Load Order Import',
action: this.props.onImportList,
default: true,
}, {
icon: (this.state.updating || this.props.disabled) ? 'spinner' : 'import',
title: 'Load Order Export',
action: this.props.onExportList,
}]
}
}
}
];
}

public UNSAFE_componentWillReceiveProps(newProps: IProps) {
// Zuckerberg isn't going to like this...
if (this.state.currentRefreshId !== newProps.refreshId) {
if (!!newProps.refreshId && this.state.currentRefreshId !== newProps.refreshId) {
this.nextState.currentRefreshId = newProps.refreshId;
this.onRefreshList();
return;
Expand Down Expand Up @@ -298,18 +322,22 @@ class FileBasedLoadOrderPage extends ComponentEx<IProps, IComponentState> {
.finally(() => onSetOrder(profile.id, newLO));
}

private onImportList = () => {

}

private onRefreshList = () => {
const { onStartUp, onSetOrder, profile } = this.props;
this.nextState.updating = true;
onStartUp(profile?.gameId)
.then(lo => {
this.nextState.validationError = undefined;
onSetOrder(profile.id, lo);
onSetOrder(profile.id, lo, true);
})
.catch(err => {
if (err instanceof LoadOrderValidationError) {
this.nextState.validationError = err as LoadOrderValidationError;
onSetOrder(profile.id, err.loadOrder);
onSetOrder(profile.id, err.loadOrder, true);
}
})
.finally(() => this.nextState.updating = false);
Expand All @@ -336,9 +364,6 @@ function mapDispatchToProps(dispatch: any): IActionProps {
return {
onSetDeploymentNecessary: (gameId: string, necessary: boolean) =>
dispatch(actions.setDeploymentNecessary(gameId, necessary)),
onSetOrder: (profileId: string, loadOrder: types.LoadOrder) => {
dispatch(setFBLoadOrder(profileId, loadOrder));
},
onForceRefresh: (profileId: string) => {
dispatch(setFBForceUpdate(profileId))
},
Expand Down

0 comments on commit a5991de

Please sign in to comment.