Skip to content

Commit

Permalink
Merge pull request #667 from Nexus-Mods/suggestions-for-advancedinsta…
Browse files Browse the repository at this point in the history
…ller

Added: Basic Suggesions for AdvancedInstaller
  • Loading branch information
Al12rs authored Sep 27, 2023
2 parents a2599d9 + 724be11 commit 9b34405
Show file tree
Hide file tree
Showing 14 changed files with 290 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Advanced Installer: Suggestions

This document describes the design of the 'Suggestions' system within Advanced Installer, pictured
in the following mockup below:

![There is an image here, once it's in the ADRs folder, I promise >w<](./images/0009-advanced-installer-location.png)

## Context Statement

When using the Advanced Installer, the user should be provided with 'hints' dictating where a file might
require to be placed based on a number of heuristics.

This is part of the UX effort to 'Make Modding Easy' and a general requirement of our [Advanced Installer Design](./0009-advanced-installer-design.md).

## Considered Options (Suggestions)

- Reusing the Deployment System (`InstallFolderTarget`) for suggestions.
- Creating a Suggestion System from scratch.

## Decision Outcome

Rather than splitting the metadata in two, we can leverage the existing
`InstallFolderTarget` system in order to implement Advanced Installer suggestions;
as that system already has required metadata.

### Consequences

- [Good] Strong code reuse as `InstallFolderTarget` already has required metadata to support this functionality.
- [Neutral] Each `InstallFolderTarget` folder will now need a description.
- [Neutral] All games will need to be converted to new `InstallFolderTarget` system.
- [Negative] The `InstallFolderTarget` list may not contain all folders that the user may want to drop their files manually to.

## Implementation: Reuse of `InstallFolderTarget`

The parts of `InstallFolderTarget` which are usable by the suggestion system will be lifted out into a new interface,
shown below:

```csharp
/// <summary>
/// Represents a target used for suggestions for installing mods within the Advanced Installer.
/// </summary>
public interface IModInstallDestination
{
/// <summary>
/// GamePath to which the relative mod file paths should appended to.
/// </summary>
public GamePath DestinationGamePath { get; init; }

/// <summary>
/// List of known recognizable file extensions for direct children of the target <see cref="DestinationGamePath"/>.
/// NOTE: Only include file extensions that are only likely to appear at this level of the folder hierarchy.
/// </summary>
public IEnumerable<Extension> KnownValidFileExtensions { get; init; }

/// <summary>
/// List of file extensions to discard when installing to this target.
/// </summary>
public IEnumerable<Extension> FileExtensionsToDiscard { get; init; }
}
```

## Acquiring `IModInstallDestination`(s) During Deploy Step (a.k.a. `GetModsAsync`)

Extend the `GameInstallation` abstract class to expose a property which returns `IModInstallDestination`(s) for the game's most common directories.
To do this, we will add an abstract method into `AGame`, to accompany the existing `GetLocations()` method.

This property is populated with the following elements:
- All `GamePath` entries (i.e. Game folder, Save folder, Config folder, etc.)
- All `InstallFolderTarget` entries (e.g. `Data` folder for Skyrim.), as `IModInstallDestination`.
- Custom `IModInstallDestination`(s) defined on a per game basis.
- Remove duplicates.
- Apply the filtering steps detailed below. (Detailed in future ADR/Issue)

This interface can be accessed during the deploy step under `GameInstallation` structure.

Note: Currently we don't auto fetch, `InstallFolderTarget`(s) [as in, auto add targets with 0 code] for the following reason(s):
- We might potentially one day have directories that `GenericFolderMatchInstaller` might use, but we don't want to display to user.
- We can only pull locations from `GenericFolderMatchInstaller` in current code, but that lives in an inaccessible package/project from `AGame`.
12 changes: 11 additions & 1 deletion src/Games/NexusMods.Games.BethesdaGameStudios/ABethesdaGame.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using NexusMods.DataModel.Games;
using NexusMods.DataModel.Extensions;
using NexusMods.DataModel.Games;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.ModInstallers;
using NexusMods.Games.FOMOD;
using NexusMods.Games.Generic.Installers;
Expand Down Expand Up @@ -29,4 +31,12 @@ protected ABethesdaGame(IEnumerable<IGameLocator> gameLocators, IServiceProvider

/// <inheritdoc />
public override IEnumerable<IModInstaller> Installers => _installers;

public override List<IModInstallDestination> GetInstallDestinations(IReadOnlyDictionary<LocationId, AbsolutePath> locations)
{
var result = new List<IModInstallDestination>();
ModInstallDestinationHelpers.AddInstallFolderTargets(BethesdaInstallFolderTargets.InstallFolderTargets(), result);
ModInstallDestinationHelpers.AddCommonLocations(locations, result);
return result;
}
}
4 changes: 4 additions & 0 deletions src/Games/NexusMods.Games.DarkestDungeon/DarkestDungeon.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using NexusMods.Common;
using NexusMods.DataModel.Games;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.ModInstallers;
using NexusMods.FileExtractor.StreamFactories;
using NexusMods.Games.DarkestDungeon.Installers;
Expand Down Expand Up @@ -75,6 +76,9 @@ protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IF
return result;
}

public override List<IModInstallDestination> GetInstallDestinations(IReadOnlyDictionary<LocationId, AbsolutePath> locations) =>
ModInstallDestinationHelpers.GetCommonLocations(locations);

public override IStreamFactory Icon =>
new EmbededResourceStreamFactory<DarkestDungeon>("NexusMods.Games.DarkestDungeon.Resources.DarkestDungeon.icon.png");

Expand Down
4 changes: 4 additions & 0 deletions src/Games/NexusMods.Games.RedEngine/Cyberpunk2077.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Games;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.ModInstallers;
using NexusMods.FileExtractor.StreamFactories;
using NexusMods.Games.RedEngine.ModInstallers;
Expand Down Expand Up @@ -61,4 +62,7 @@ protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IF
new AppearancePreset(_serviceProvider),
new FolderlessModInstaller()
};

public override List<IModInstallDestination> GetInstallDestinations(IReadOnlyDictionary<LocationId, AbsolutePath> locations)
=> ModInstallDestinationHelpers.GetCommonLocations(locations);
}
4 changes: 4 additions & 0 deletions src/Games/NexusMods.Games.Sifu/Sifu.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NexusMods.DataModel.Games;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.ModInstallers;
using NexusMods.Paths;

Expand Down Expand Up @@ -34,4 +35,7 @@ protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IF

/// <inheritdoc />
public override IEnumerable<IModInstaller> Installers => new[] { new SifuModInstaller(_serviceProvider) };

public override List<IModInstallDestination> GetInstallDestinations(IReadOnlyDictionary<LocationId, AbsolutePath> locations)
=> ModInstallDestinationHelpers.GetCommonLocations(locations);
}
5 changes: 4 additions & 1 deletion src/Games/NexusMods.Games.StardewValley/StardewValley.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using JetBrains.Annotations;
using NexusMods.Common;
using NexusMods.DataModel;
using NexusMods.DataModel.Games;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.ModInstallers;
using NexusMods.FileExtractor.StreamFactories;
using NexusMods.Games.StardewValley.Installers;
Expand Down Expand Up @@ -78,4 +78,7 @@ protected override IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IF
SMAPIModInstaller.Create(_serviceProvider)
};

public override List<IModInstallDestination> GetInstallDestinations(
IReadOnlyDictionary<LocationId, AbsolutePath> locations)
=> ModInstallDestinationHelpers.GetCommonLocations(locations);
}
31 changes: 22 additions & 9 deletions src/NexusMods.DataModel/Games/AGame.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.Loadouts;
using NexusMods.DataModel.ModInstallers;
using NexusMods.Paths;
Expand Down Expand Up @@ -79,16 +80,22 @@ public void ResetInstallations()

private List<GameInstallation> GetInstallations()
{
return (from locator in _gamelocators
from installation in locator.Find(this)
select new GameInstallation
return (_gamelocators.SelectMany(locator => locator.Find(this),
(locator, installation) =>
{
Game = this,
LocationsRegister = new GameLocationsRegister(new Dictionary<LocationId, AbsolutePath>(
GetLocations(installation.Path.FileSystem, installation))),
Version = installation.Version ?? GetVersion(installation),
Store = installation.Store
})
var locations = GetLocations(installation.Path.FileSystem,
installation);
return new GameInstallation
{
Game = this,
LocationsRegister =
new GameLocationsRegister(
new Dictionary<LocationId, AbsolutePath>(locations)),
InstallDestinations = GetInstallDestinations(locations),
Version = installation.Version ?? GetVersion(installation),
Store = installation.Store
};
}))
.DistinctBy(g => g.LocationsRegister[LocationId.Game])
.ToList();
}
Expand All @@ -106,6 +113,12 @@ from installation in locator.Find(this)
protected abstract IReadOnlyDictionary<LocationId, AbsolutePath> GetLocations(IFileSystem fileSystem,
GameLocatorResult installation);

/// <summary>
/// Returns the locations of installation destinations used by the Advanced Installer.
/// </summary>
/// <param name="locations">Result of <see cref="GetLocations"/>.</param>
public abstract List<IModInstallDestination> GetInstallDestinations(IReadOnlyDictionary<LocationId, AbsolutePath> locations);

/// <inheritdoc />
public override string ToString() => Name;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using NexusMods.Paths;

namespace NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;

/// <summary>
/// Represents a target used for where user mods may be manually installed when using human assisted installers
/// such as the Advanced Installer.
/// </summary>
public interface IModInstallDestination
{
/// <summary>
/// GamePath to which the relative mod file paths should appended to.
/// </summary>
public GamePath DestinationGamePath { get; }
}

/// <summary>
/// Helper methods for <see cref="IModInstallDestination"/>.
/// </summary>
public static class ModInstallDestinationHelpers
{
/// <summary>
/// Converts a list of <see cref="InstallFolderTarget"/>(s) into <see cref="IModInstallDestination"/>.
/// </summary>
/// <param name="target">The target to create a mod install destination from.</param>
/// <param name="accumulator">List to return the results into.</param>
public static void AddInstallFolderTarget(InstallFolderTarget target, List<IModInstallDestination> accumulator)
{
accumulator.Add(target);
foreach (var subTarget in target.SubTargets)
AddInstallFolderTarget(subTarget, accumulator);
}

/// <summary>
/// Converts a list of <see cref="InstallFolderTarget"/>(s) into <see cref="IModInstallDestination"/>.
/// </summary>
/// <param name="targets">Collection of targets to get destinations from.</param>
/// <param name="accumulator">List to return the results into.</param>
public static void AddInstallFolderTargets(IEnumerable<InstallFolderTarget> targets, List<IModInstallDestination> accumulator)
{
foreach (var target in targets)
AddInstallFolderTarget(target, accumulator);
}

/// <summary>
/// Adds a list of common locations (passed via parameter) to accumulator of <see cref="IModInstallDestination"/>.
/// </summary>
/// <param name="locations">Locations to add to the accumulator.</param>
/// <param name="accumulator">List to return the results into.</param>
public static void AddCommonLocations(IReadOnlyDictionary<LocationId, AbsolutePath> locations, List<IModInstallDestination> accumulator)
{
foreach (var location in locations)
{
accumulator.Add(new InstallFolderTarget()
{
// Locations has
DestinationGamePath = new GamePath(location.Key, ""),
KnownSourceFolderNames = Array.Empty<string>(),
KnownValidSubfolders = Array.Empty<string>(),
KnownValidFileExtensions = Array.Empty<Extension>(),
FileExtensionsToDiscard = Array.Empty<Extension>(),
SubPathsToDiscard = Array.Empty<RelativePath>()
});
}
}

/// <summary>
/// Converts a list of common locations (passed via parameter) to <see cref="IModInstallDestination"/>.
/// </summary>
/// <param name="locations">Locations to add to the accumulator.</param>
public static List<IModInstallDestination> GetCommonLocations(IReadOnlyDictionary<LocationId, AbsolutePath> locations)
{
var result = new List<IModInstallDestination>();
AddCommonLocations(locations, result);
return result;
}

/// <summary>
/// Converts a list of <see cref="InstallFolderTarget"/>(s) into <see cref="IModInstallDestination"/>.
/// </summary>
/// <param name="targets">Collection of targets to get destinations from.</param>
/// <returns>Collection of destinations.</returns>
public static List<IModInstallDestination> FromInstallFolderTargets(IEnumerable<InstallFolderTarget> targets)
{
var result = new List<IModInstallDestination>();
AddInstallFolderTargets(targets, result);
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapabil
/// Each <see cref="InstallFolderTarget"/> represents a single game location and
/// contains information useful for recognizing and installing mod file paths to that location.
/// </summary>
public class InstallFolderTarget
public class InstallFolderTarget : IModInstallDestination
{
/// <summary>
/// GamePath to which the relative mod file paths should appended to.
Expand Down
5 changes: 5 additions & 0 deletions src/NexusMods.DataModel/Games/GameInstallation.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.Paths;

namespace NexusMods.DataModel.Games;
Expand All @@ -13,6 +14,10 @@ public class GameInstallation
/// </summary>
public Version Version { get; init; } = new();

/// <summary>
/// Contains the manual install destinations for AdvancedInstaller and friends.
/// </summary>
public List<IModInstallDestination> InstallDestinations { get; init; } = new();

/// <summary>
/// The location on-disk of this game and it's associated paths [e.g. Saves].
Expand Down
1 change: 1 addition & 0 deletions src/NexusMods.DataModel/Games/IGame.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.Loadouts;
using NexusMods.DataModel.ModInstallers;

Expand Down
3 changes: 3 additions & 0 deletions src/NexusMods.DataModel/Games/Unknown/UnknownGame.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using NexusMods.Common;
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.Games.GameCapabilities.FolderMatchInstallerCapability;
using NexusMods.DataModel.Loadouts;
using NexusMods.DataModel.ModInstallers;
using NexusMods.Paths;
Expand Down Expand Up @@ -53,6 +54,8 @@ public IEnumerable<AModFile> GetGameFiles(GameInstallation installation, IDataSt
/// <inheritdoc />
public IEnumerable<IModInstaller> Installers => Array.Empty<IModInstaller>();

/// <inheritdoc />
public List<IModInstallDestination> InstallDestinations { get; } = new();

/// <inheritdoc />
public IStreamFactory Icon => throw new NotImplementedException("No icon provided for this game.");
Expand Down
Loading

0 comments on commit 9b34405

Please sign in to comment.