Skip to content

Commit

Permalink
Merge pull request #1294 from Nexus-Mods/marker-loadouts
Browse files Browse the repository at this point in the history
Add Marker Loadouts & Minor Improvements
  • Loading branch information
Sewer56 authored May 2, 2024
2 parents 1def77f + 2898003 commit 37a6e00
Show file tree
Hide file tree
Showing 15 changed files with 250 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
using NexusMods.Abstractions.Loadouts.Visitors;
using NexusMods.Abstractions.Serialization;
using NexusMods.Abstractions.Serialization.DataModel;
using NexusMods.Abstractions.Serialization.DataModel.Ids;
using NexusMods.Extensions.BCL;
using NexusMods.Hashing.xxHash64;
using NexusMods.Paths;
Expand Down Expand Up @@ -585,12 +584,21 @@ public virtual Loadout MergeLoadouts(Loadout loadoutA, Loadout loadoutB)
/// <returns></returns>
public virtual async Task<DiskStateTree> Apply(Loadout loadout, bool forceSkipIngest = false)
{
// Note(Sewer) If the last loadout was a marker loadout, we need to
// skip ingest and ignore changes, since marker loadouts should not be changed.
// (Prevent 'Needs Ingest' exception)
forceSkipIngest = forceSkipIngest || IsLastLoadoutAMarkerLoadout(loadout.Installation);

var flattened = await LoadoutToFlattenedLoadout(loadout);
var fileTree = await FlattenedLoadoutToFileTree(flattened, loadout);
var prevState = _diskStateRegistry.GetState(loadout.Installation)!;
var diskState = await FileTreeToDisk(fileTree, loadout, flattened, prevState, loadout.Installation, forceSkipIngest);
diskState.LoadoutRevision = loadout.DataStoreId;
await _diskStateRegistry.SaveState(loadout.Installation, diskState);

if (!loadout.IsMarkerLoadout())
RemoveMarkerLoadout();

return diskState;
}

Expand Down Expand Up @@ -840,12 +848,14 @@ private Mod CreateGameFilesMod(DiskStateTree initialState)
/// <inheritdoc />
public async Task UnManage(GameInstallation installation)
{
await ResetToInitialState(installation);
await FastForceApplyOriginalState(installation);

// Cleanup all of the metadata left behind for this game.
// All database information, including loadouts, initial game state and
// TODO: Garbage Collect unused files.
foreach (var loadoutId in _loadoutRegistry.AllLoadoutIds().ToArray())
_loadoutRegistry.Delete(loadoutId);

// Now clear the vanilla game state.
// We don't want the user's folder to reset.
await _diskStateRegistry.ClearInitialState(installation);
}

Expand All @@ -855,67 +865,80 @@ public async Task DeleteLoadout(GameInstallation installation, LoadoutId id)
// Clear Initial State if this is the only loadout for the game.
// We use folder location for this.
var installLocation = installation.LocationsRegister[LocationId.Game].ToString();
var wasLastLoadout = _loadoutRegistry
var isLastLoadout = _loadoutRegistry
.AllLoadouts()
.Count(x => x.Installation.LocationsRegister[LocationId.Game].ToString() == installLocation) <= 1;
.Count(x =>
x.Installation.LocationsRegister[LocationId.Game].ToString() == installLocation
&& !x.IsMarkerLoadout()) <= 1;

if (isLastLoadout)
{
await UnManage(installation);
return;
}

if (_loadoutRegistry.IsApplied(id, _diskStateRegistry.GetLastAppliedLoadout(installation)))
{
/*
Note(Sewer)
The loadout being deleted is the currently active loadout
The loadout being deleted is the currently active loadout.
As a 'default' reasonable behaviour, we will reset the game folder
to its initial state. This is a good default as in most cases,
game files are not likely to be overwritten, so this will just
end up materialising into a bunch of deletes. (Very Fast)
to its initial state and create a 'hidden' loadout to accomodate this.
In the future, we may make a setting to change the behaviour,
if for example the user wants it to revert to last applied loadout
that isn't the one being deleted.
This is a good default for many cases:
- Game files are not likely to be overwritten, so this will
just end up materialising into a bunch of deletes. (Very Fast)
- Ensures internal consistency. i.e. 'last applied loadout' is always
a valid loadout.
- Provides more backend flexibility (e.g. we can 'squash' the
revisions at the beginning of other loadouts without consequence.)
- Meets user UI/UX expectations. The next loadout they navigate to
won't be somehow magically applied.
We may make a setting to change the behaviour in the future,
via a setting to match user preferences, but for now this is the
default.
*/

await ResetToInitialState(installation);
var markerLoadout = await CreateMarkerLoadout(installation);
await Apply(markerLoadout, true);
}

_loadoutRegistry.Delete(id);
if (wasLastLoadout)
await _diskStateRegistry.ClearInitialState(installation);
}

private async Task ResetToInitialState(GameInstallation installation)

/// <summary>
/// Creates a 'Marker Loadout', which is a loadout embued with the initial
/// state of the game folder.
///
/// This loadout is created when the last applied loadout for a game
/// is deleted. And is deleted when a non-marker loadout is applied.
/// It should be a singleton.
/// </summary>
private async Task<Loadout> CreateMarkerLoadout(GameInstallation installation)
{
var (isCached, initialState) = await GetOrCreateInitialDiskState(installation);
if (!isCached)
throw new InvalidOperationException("Something is very wrong with the DataStore.\n" +
"We don't have an initial disk state for the game folder, but we should have.\n" +
"Because this disk state should only ever be deleted when unmanaging a game.");

/*
Note (Sewer)
Normally we could navigate to the very first revision of a loadout
to get the 'vanilla' state of the game folder and apply that, however...
var initialState = await GetOrCreateInitialDiskState(installation);
var loadoutId = LoadoutId.Create();
var gameFiles = CreateGameFilesMod(initialState.tree);
var fileTree = CreateFileTreeFromMod(gameFiles);
await BackupNewFiles(installation, fileTree);

In the future we will support rollbacks for loadouts.
That means the user will be able to update the 'starting'
state of a given loadout, deleting previous history to save space.
In order to keep future code 'simpler', where we won't have to make
special exceptions/logic. We just perform a stripped down apply
with the original file tree here.
*/
var loadout = _loadoutRegistry.Alter(loadoutId, "Marker Loadout", loadout => loadout with
{
Name = "Temporary Marker Loadout",
Installation = installation,
Mods = loadout.Mods.With(gameFiles.Id, gameFiles),
LoadoutKind = LoadoutKind.Marker,
});

// Create a non-persisted mod from original game files.
// Then effectively 'Apply' it as single mod 'Loadout'.
var gameFilesMod = CreateGameFilesMod(initialState);
var fileTree = CreateFileTreeFromMod(gameFilesMod);
var flattened = ModsToFlattenedLoadout([gameFilesMod]);
var prevState = _diskStateRegistry.GetState(installation)!;
await FileTreeToDisk(fileTree, null, flattened, prevState, installation, true);
return loadout;
}

#endregion

#region FlattenLoadoutToTree Methods
Expand All @@ -936,7 +959,6 @@ protected virtual async ValueTask<ISortRule<Mod, ModId>[]> ModSortRules(Loadout
return await builtInSortRules.ToAsyncEnumerable().Concat(customSortRules).ToArrayAsync();
}


/// <summary>
/// Sorts the mods in a loadout.
/// </summary>
Expand All @@ -957,8 +979,38 @@ protected virtual async Task<IEnumerable<Mod>> SortMods(Loadout loadout)
}
#endregion


#region Misc Helper Functions
/// <summary>
/// Checks if the last applied loadout is a 'marker' loadout.
/// </summary>
/// <param name="installation">The game installation to check.</param>
/// <returns>True if the last applied loadout is a marker loadout.</returns>
private bool IsLastLoadoutAMarkerLoadout(GameInstallation installation)
{
var lastId = _diskStateRegistry.GetLastAppliedLoadout(installation);
if (lastId == null)
return false;

var loadout = _loadoutRegistry.GetLoadout(lastId);
return loadout != null && loadout.IsMarkerLoadout();
}

/// <summary>
/// Removes the first found 'marker' loadout from the data store.
/// </summary>
/// <remarks>
/// The 'marker loadout' should be a singleton as per <see cref="Loadout.IsMarkerLoadout"/>
/// and <see cref="CreateMarkerLoadout"/>.
/// </remarks>
private void RemoveMarkerLoadout()
{
foreach (var loadout in _loadoutRegistry.AllLoadouts())
{
if (!loadout.IsMarkerLoadout()) continue;
_loadoutRegistry.Delete(loadout.LoadoutId);
return;
}
}

/// <summary>
/// By default this method just returns the current state of the game folders. Most of the time
Expand All @@ -981,6 +1033,28 @@ protected virtual async Task<IEnumerable<Mod>> SortMods(Loadout loadout)
await _diskStateRegistry.SaveInitialState(installation, indexedState);
return (false, indexedState);
}

/// <summary>
/// Quickly reverts the folder to its original state, without updating
/// the database state or doing any other changes that produce
/// external side effects.
///
/// Intended for use when unmanaging a game.
/// </summary>
private async Task FastForceApplyOriginalState(GameInstallation installation)
{
var (isCached, initialState) = await GetOrCreateInitialDiskState(installation);
if (!isCached)
throw new InvalidOperationException("Something is very wrong with the DataStore.\n" +
"We don't have an initial disk state for the game folder, but we should have.\n" +
"Because this disk state should only ever be deleted when unmanaging a game.");

var gameFilesMod = CreateGameFilesMod(initialState);
var fileTree = CreateFileTreeFromMod(gameFilesMod);
var flattened = ModsToFlattenedLoadout([gameFilesMod]);
var prevState = _diskStateRegistry.GetState(installation)!;
await FileTreeToDisk(fileTree, null, flattened, prevState, installation, true);
}
#endregion

#region Internal Helper Functions
Expand Down
24 changes: 24 additions & 0 deletions src/Abstractions/NexusMods.Abstractions.Loadouts/Loadout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ public record Loadout : Entity, IEmptyWithDataStore<Loadout>
/// Link to the previous version of this loadout on the data store.
/// </summary>
public required EntityLink<Loadout> PreviousVersion { get; init; }

/// <summary>
/// Specifies the type of the loadout that the current loadout represents
/// </summary>
public LoadoutKind LoadoutKind { get; init; }

/// <inheritdoc />
public override EntityCategory Category => EntityCategory.Loadouts;
Expand Down Expand Up @@ -123,4 +128,23 @@ public Loadout AlterFiles(Func<AModFile, AModFile?> func)
})
};
}

/// <summary>
/// This is true if the loadout is a hidden 'Marker' loadout.
/// A marker loadout is created from the original game state and should
/// be a singleton for a given game. It is a temporary loadout that is
/// destroyed when a real loadout is applied.
///
/// Marker loadouts should not be shown in any user facing elements.
/// </summary>
public bool IsMarkerLoadout() => LoadoutKind == LoadoutKind.Marker;

/// <summary>
/// Returns true if the loadout should be visible to the user.
/// </summary>
/// <remarks>
/// Note(sewer), it's better to 'opt into' functionality, than opt out.
/// especially, when it comes to displaying elements the user can edit.
/// </remarks>
public bool IsVisible() => LoadoutKind == LoadoutKind.Default;
}
21 changes: 21 additions & 0 deletions src/Abstractions/NexusMods.Abstractions.Loadouts/LoadoutKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace NexusMods.Abstractions.Loadouts;

/// <summary>
/// Specifies the 'type' of loadout that the current loadout represents.
/// </summary>
public enum LoadoutKind : byte
{
/// <summary>
/// This is a regular loadout that's created by the user
/// </summary>
Default,

/// <summary>
/// This is a temporary loadout that is created from the original game state
/// upon removal of the currently active loadout. This loadout is removed
/// when a 'real' loadout is applied.
///
/// A GameInstallation should only have 1 marker loadout.
/// </summary>
Marker,
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ public void Do()
}
}

file record ExampleContext : IWorkspaceContext;
file record ExampleContext : IWorkspaceContext
{
// Use this method to verify if the data in the context is still valid.
// The workspace will be discarded if the data becomes invalid.
public bool IsValid(IServiceProvider serviceProvider) => true;
}

// This example class shows how to query Windows and Workspaces from within a page.
file class ExamplePage : APageViewModel<IMyPageInterface>, IMyPageInterface
Expand Down
21 changes: 19 additions & 2 deletions src/NexusMods.App.UI/Controls/Spine/SpineViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ public SpineViewModel(
Downloads.WorkspaceContext = new DownloadsContext();
_specialSpineItems.Add(Downloads);
Downloads.Click = ReactiveCommand.Create(NavigateToDownloads);


if (!_windowManager.TryGetActiveWindow(out var currentWindow)) return;
var workspaceController = currentWindow.WorkspaceController;
Expand All @@ -78,7 +77,7 @@ public SpineViewModel(
{
loadoutRegistry.LoadoutRootChanges
.Transform(loadoutId => (loadoutId, loadout: loadoutRegistry.Get(loadoutId)))
.Filter(tuple => tuple.loadout != null)
.Filter(tuple => tuple.loadout != null && tuple.loadout.IsVisible())
.TransformAsync(async tuple =>
{
var loadoutId = tuple.loadoutId;
Expand Down Expand Up @@ -118,6 +117,24 @@ public SpineViewModel(
.SubscribeWithErrorLogging()
.DisposeWith(disposables);

// Navigate away from the Loadout workspace if the Loadout is removed
loadoutRegistry.LoadoutRootChanges
.OnUI()
.OnItemRemoved(loadoutId =>
{
if (workspaceController.ActiveWorkspace?.Context is LoadoutContext activeLoadoutContext &&
activeLoadoutContext.LoadoutId == loadoutId)
{
workspaceController.ChangeOrCreateWorkspaceByContext<HomeContext>(() => new PageData
{
FactoryId = MyGamesPageFactory.StaticId,
Context = new MyGamesPageContext(),
});
}
}, false)
.SubscribeWithErrorLogging()
.DisposeWith(disposables);

// Update the LeftMenuViewModel when the active workspace changes
workspaceController.WhenAnyValue(controller => controller.ActiveWorkspace)
.Select(workspace => workspace?.Id)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
using System.Collections.ObjectModel;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using DynamicData;
using DynamicData.Kernel;
using Microsoft.Extensions.DependencyInjection;
using NexusMods.Abstractions.Diagnostics;
using NexusMods.Abstractions.Loadouts;
using NexusMods.App.UI.Controls.Navigation;
using NexusMods.App.UI.LeftMenu.Items;
using NexusMods.App.UI.Pages.Diagnostics;
using NexusMods.App.UI.Pages.LoadoutGrid;
using NexusMods.App.UI.Pages.ModLibrary;
using NexusMods.App.UI.Pages.MyGames;
using NexusMods.App.UI.Resources;
using NexusMods.App.UI.WorkspaceSystem;
using NexusMods.Icons;
Expand All @@ -29,7 +33,8 @@ public LoadoutLeftMenuViewModel(
IServiceProvider serviceProvider)
{
var diagnosticManager = serviceProvider.GetRequiredService<IDiagnosticManager>();

var loadoutRegistry = serviceProvider.GetRequiredService<ILoadoutRegistry>();

WorkspaceId = workspaceId;
ApplyControlViewModel = new ApplyControlViewModel(loadoutContext.LoadoutId, serviceProvider);

Expand Down
Loading

0 comments on commit 37a6e00

Please sign in to comment.