Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fixed intermittent loadorder reset when installing a mod #16857

Merged
merged 3 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 4 additions & 1 deletion src/extensions/file_based_loadorder/collections/loadOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { findGameEntry } from '../gameSupport';
import { genCollectionLoadOrder } from '../util';

import LoadOrderCollections from '../views/LoadOrderCollections';
import UpdateSet from '../UpdateSet';

export async function generate(api: types.IExtensionApi,
state: types.IState,
Expand Down Expand Up @@ -49,14 +50,16 @@ export async function generate(api: types.IExtensionApi,

export async function parser(api: types.IExtensionApi,
gameId: string,
collection: ICollection): Promise<void> {
collection: ICollection,
updateSet: UpdateSet): Promise<void> {
const state = api.getState();

const profileId = selectors.lastActiveProfileForGame(state, gameId);
if (profileId === undefined) {
return Promise.reject(new CollectionParseError(collection, 'Invalid profile id'));
}

updateSet.init(gameId, (collection.loadOrder ?? []).map((lo, index) => ({ ...lo, index })));
api.store.dispatch(setFBLoadOrder(profileId, collection.loadOrder));
return Promise.resolve(undefined);
}
Expand Down
71 changes: 69 additions & 2 deletions 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 All @@ -298,7 +354,7 @@ export default function init(context: IExtensionContext) {
util.getSafe(state, ['persistent', 'mods', gameId], {});
return generate(context.api, state, gameId, stagingPath, includedMods, mods);
},
(gameId: string, collection: ICollection) => parser(context.api, gameId, collection),
(gameId: string, collection: ICollection) => parser(context.api, gameId, collection, updateSet),
() => Promise.resolve(),
(t) => t('Load Order'),
(state: types.IState, gameId: string) => {
Expand All @@ -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 @@ -304,12 +328,12 @@ class FileBasedLoadOrderPage extends ComponentEx<IProps, IComponentState> {
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 +360,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