This commit is contained in:
iisaev committed 2026-10-18 12:41:39 +03:00
commit e8e0c5159d
46 files changed
+7923

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
namespace Mods.ModManager
{
/// <summary>
/// User-triggered mod operation. The screen resolves a tile's primary action
/// via <see cref="ModActions.GetPrimary"/> and executes the kind here; the
/// detail view maps its buttons to the same kinds. Every kind is persisted to
/// disk immediately but only takes effect after a restart.
/// </summary>
public enum ModActionKind
{
/// <summary>Download and install the selected catalog version.</summary>
Install,
/// <summary>Install a newer catalog version over the local copy.</summary>
Update,
/// <summary>Install an older catalog version over the local copy.</summary>
Downgrade,
/// <summary>Reinstall the same version to replace locally modified files.</summary>
Reinstall,
/// <summary>Delete the installed mod (user mods root only), then clean per-mod state.</summary>
Delete,
/// <summary>Allow the mod to load on next start.</summary>
Enable,
/// <summary>Prevent the mod from loading on next start.</summary>
Disable,
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Mods.ModManager
{
/// <summary>
/// Business rules deciding which operation a mod tile offers. The tile shows
/// a single contextual button; its meaning follows the state machine:
/// catalog-only → Install, newer remote → Update, older remote → Downgrade,
/// same version but edited locally → Reinstall, otherwise → Delete for
/// deletable local mods and nothing for protected/shipped ones.
/// </summary>
public static class ModActions
{
/// <summary>Primary action for a tile/detail page, or null when there is nothing to do.</summary>
public static ModActionKind? GetPrimary(ModCardData data)
{
if (data == null)
return null;
if (!data.IsInstalled)
return data.HasRemote ? ModActionKind.Install : null;
if (data.HasRemote)
{
switch (data.Status)
{
case ModItemStatus.UpdateAvailable:
return ModActionKind.Update;
case ModItemStatus.DowngradeAvailable:
return ModActionKind.Downgrade;
case ModItemStatus.ModifiedLocally:
return ModActionKind.Reinstall;
}
}
return data.IsDeletable ? ModActionKind.Delete : null;
}
/// <summary>True for actions that download and install files.</summary>
public static bool IsInstall(ModActionKind kind)
=> kind == ModActionKind.Install
|| kind == ModActionKind.Update
|| kind == ModActionKind.Downgrade
|| kind == ModActionKind.Reinstall;
}
}
+155
View File
@@ -0,0 +1,155 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
namespace Mods.ModManager
{
/// <summary>
/// One row of the mod manager grid: everything the tile and the detail page
/// need to render a mod, regardless of whether it exists locally, in the
/// online catalog, or both. The manager builds these in
/// <see cref="ModManagerModel.Reload"/> by merging
/// <c>ModRegistry.AllDiscoveredMods</c> with <c>CatalogService.CachedEntries</c>.
/// <para/>
/// <see cref="Status"/> is derived (not stored by the registry) from the local
/// version, the selected catalog download and the local install hash — call
/// <see cref="RecomputeStatus"/> after changing <see cref="SelectedDownload"/>,
/// <see cref="IsDisabled"/> or the install state.
/// </summary>
public class ModCardData
{
/// <summary>Stable mod id shared by the local mod and the catalog entry.</summary>
public string Id { get; set; }
/// <summary>Localized display title (local manifest name or catalog locale name).</summary>
public string Title { get; set; }
/// <summary>Author string from whichever source is available.</summary>
public string Author { get; set; }
/// <summary>Localized description.</summary>
public string Description { get; set; }
/// <summary>Installed version string, or null for catalog-only mods.</summary>
public string LocalVersion { get; set; }
/// <summary>Manifest category (local) or catalog category (remote), flags combined.</summary>
public CategoryType Category { get; set; } = CategoryType.Other;
/// <summary>Loaded local mod, or null when only the catalog knows this id.</summary>
public ModInfo LocalInfo { get; set; }
/// <summary>Merged catalog entry, or null for local-only (development) mods.</summary>
public CatalogExportEntry Catalog { get; set; }
/// <summary>True when a local copy exists (including an install done in this session).</summary>
public bool IsInstalled { get; set; }
/// <summary>True when the local copy is listed in <c>disabled_mods.json</c>.</summary>
public bool IsDisabled { get; set; }
/// <summary>True when <see cref="ModUninstaller"/> may delete the local copy.</summary>
public bool IsDeletable { get; set; }
/// <summary>True for the mod manager itself, which cannot be disabled or deleted.</summary>
public bool IsProtected { get; set; }
/// <summary>Derived tile state; see <see cref="RecomputeStatus"/>.</summary>
public ModItemStatus Status { get; set; }
/// <summary>Catalog downloads, newest first (as merged by the catalog service).</summary>
public List<CatalogEntryDownload> Downloads { get; set; } = new();
/// <summary>Version chosen in the tile/detail combo box; defaults to the newest download.</summary>
public CatalogEntryDownload SelectedDownload { get; set; }
/// <summary>True when an online catalog entry exists for this mod.</summary>
public bool HasRemote => Catalog != null;
/// <summary>
/// True when several catalogs publish this id with the same version and
/// title but different content — the tile and detail page warn about it.
/// </summary>
public bool IsConflict => Catalog?.VariantConflict == true;
/// <summary>
/// How many <see cref="ModCollectionKind.Game"/> collections reference
/// this mod (directly or through includes). Computed by the manager
/// screen via <see cref="ModCollectionGallery.FindGamesUsingMod"/> on
/// every grid refresh; zero means the mod belongs to no playable game.
/// </summary>
public int CollectionsCount { get; set; }
/// <summary>Catalog URLs that published this variant, or null for local-only mods.</summary>
public IReadOnlyList<string> SourceUrls => Catalog?.SourceUrls;
/// <summary>First catalog URL; used to install exactly this variant and to match cards after reload.</summary>
public string PrimarySource => ModCatalogSource.FirstUrl(SourceUrls);
/// <summary>
/// Identity of a card across model reloads: the mod id plus the catalog
/// variant it represents. Needed because conflicting variants share an id.
/// </summary>
public string MatchKey => $"{Id}|{PrimarySource ?? string.Empty}";
/// <summary>True when the tile should show the enable/disable switch.</summary>
public bool CanToggleEnabled => IsInstalled && !IsProtected;
/// <summary>Version to display next to the title: installed version or newest catalog version.</summary>
public string DisplayVersion => !string.IsNullOrEmpty(LocalVersion)
? LocalVersion
: SelectedDownload?.Version ?? Catalog?.Version;
/// <summary>
/// Derives <see cref="Status"/> from the local and selected remote versions.
/// Priority: not installed → disabled → version comparison →
/// hash comparison. A missing catalog entry always yields
/// <see cref="ModItemStatus.Installed"/> for a local copy.
/// </summary>
public void RecomputeStatus()
{
if (!IsInstalled)
{
Status = ModItemStatus.NotInstalled;
return;
}
if (IsDisabled)
{
Status = ModItemStatus.Disabled;
return;
}
var remoteVersion = SelectedDownload?.Version ?? Catalog?.Version;
if (string.IsNullOrEmpty(remoteVersion) || string.IsNullOrEmpty(LocalVersion))
{
Status = ModItemStatus.Installed;
return;
}
var comparison = VersionHelper.Compare(remoteVersion, LocalVersion);
if (comparison > 0)
{
Status = ModItemStatus.UpdateAvailable;
return;
}
if (comparison < 0)
{
Status = ModItemStatus.DowngradeAvailable;
return;
}
var remoteHash = SelectedDownload?.InstallSha256;
if (!string.IsNullOrEmpty(remoteHash)
&& !string.IsNullOrEmpty(LocalInfo?.InstallSha256)
&& !string.Equals(LocalInfo.InstallSha256, remoteHash, System.StringComparison.OrdinalIgnoreCase))
{
Status = ModItemStatus.ModifiedLocally;
return;
}
Status = ModItemStatus.Installed;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Mods.ModManager
{
/// <summary>
/// One row of the "+ мод" picker: the mods known to the local registry and
/// the online catalog cache, merged by id. The picker excludes ids that are
/// already resolved in the scheme, so this entity only ever describes mods
/// the player can still add; <see cref="ModChoice.Id"/> becomes a new string
/// in the target mechanic's <c>mods</c> array.
/// </summary>
public class ModChoice
{
/// <summary>Canonical mod id written into the collection document.</summary>
public string Id { get; set; }
/// <summary>Display title (localized catalog name or manifest name).</summary>
public string Title { get; set; }
/// <summary>Localized description shown under the title.</summary>
public string Description { get; set; }
}
}
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
namespace Mods.ModManager
{
/// <summary>
/// Live filter state of the manager. Empty status/category sets mean "all",
/// which matches Nexus-style sidebars where checkboxes only ever narrow the
/// list. Search matches title, id and author case-insensitively. The state
/// also owns the sort mode and the optional grouping toggle.
/// </summary>
public class ModFilterState
{
/// <summary>Free-text query matched against title, id and author.</summary>
public string Search { get; set; } = string.Empty;
/// <summary>Selected statuses; empty means no status restriction.</summary>
public HashSet<ModItemStatus> Statuses { get; } = new();
/// <summary>Selected categories; empty means no category restriction.</summary>
public HashSet<CategoryType> Categories { get; } = new();
/// <summary>Grid ordering mode.</summary>
public ModSortMode Sort { get; set; } = ModSortMode.Name;
/// <summary>When true the grid renders category headers between groups.</summary>
public bool GroupByCategory { get; set; }
/// <summary>Full match: search + status + category.</summary>
public bool Matches(ModCardData item)
=> MatchesSearch(item) && MatchesStatuses(item) && MatchesCategories(item);
/// <summary>Match ignoring the category set — used to compute category counts.</summary>
public bool MatchesExceptCategory(ModCardData item)
=> MatchesSearch(item) && MatchesStatuses(item);
/// <summary>Match ignoring the status set — used to compute status counts.</summary>
public bool MatchesExceptStatus(ModCardData item)
=> MatchesSearch(item) && MatchesCategories(item);
/// <summary>Returns matching items in the configured order.</summary>
public List<ModCardData> Apply(IEnumerable<ModCardData> source)
{
var result = new List<ModCardData>();
foreach (var item in source)
{
if (Matches(item))
result.Add(item);
}
result.Sort(Compare);
return result;
}
/// <summary>Clears search, statuses and categories; keeps sort and grouping.</summary>
public void Reset()
{
Search = string.Empty;
Statuses.Clear();
Categories.Clear();
}
private int Compare(ModCardData a, ModCardData b)
{
switch (Sort)
{
case ModSortMode.Author:
var byAuthor = string.Compare(a.Author, b.Author, StringComparison.OrdinalIgnoreCase);
return byAuthor != 0 ? byAuthor : CompareTitle(a, b);
case ModSortMode.Status:
var byStatus = ModStatusNames.SortRank(a.Status).CompareTo(ModStatusNames.SortRank(b.Status));
return byStatus != 0 ? byStatus : CompareTitle(a, b);
default:
return CompareTitle(a, b);
}
}
private static int CompareTitle(ModCardData a, ModCardData b)
=> string.Compare(a.Title, b.Title, StringComparison.OrdinalIgnoreCase);
private bool MatchesSearch(ModCardData item)
{
if (string.IsNullOrWhiteSpace(Search))
return true;
var query = Search.Trim();
return Contains(item.Title, query) || Contains(item.Id, query) || Contains(item.Author, query);
}
private bool MatchesStatuses(ModCardData item)
=> Statuses.Count == 0 || Statuses.Contains(item.Status);
private bool MatchesCategories(ModCardData item)
=> Categories.Count == 0 || (item.Category & AsFlags()) != 0;
private CategoryType AsFlags()
{
var flags = (CategoryType)0;
foreach (var category in Categories)
flags |= category;
return flags;
}
private static bool Contains(string value, string query)
=> !string.IsNullOrEmpty(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase);
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace Mods.ModManager
{
/// <summary>
/// UI-level state of a mod tile. Combines the local install state with the
/// online catalog comparison so a single value drives the tile badge and the
/// contextual action button. <see cref="ModifiedLocally"/> means the local
/// content hash differs from the catalog entry of the same version — the
/// player edited the mod and overwriting would lose their changes.
/// </summary>
public enum ModItemStatus
{
/// <summary>No local copy exists; only the catalog entry is shown.</summary>
NotInstalled,
/// <summary>Installed and up-to-date with the selected catalog version (or no catalog entry).</summary>
Installed,
/// <summary>The catalog has a newer version than the installed one.</summary>
UpdateAvailable,
/// <summary>The catalog's selected version is older than the installed one.</summary>
DowngradeAvailable,
/// <summary>Same version as the catalog, but the local content hash differs.</summary>
ModifiedLocally,
/// <summary>Installed but disabled by the player (<c>disabled_mods.json</c>).</summary>
Disabled,
}
}
+264
View File
@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Godot;
namespace Mods.ModManager
{
/// <summary>
/// Merges everything known about mods into the manager's grid model: local
/// mods discovered by <see cref="ModRegistry"/> (with enable state and
/// deletability) and online catalog entries from <see cref="CatalogService"/>.
/// A mod present in both sources becomes one combined card; catalog-only mods
/// appear as installable tiles.
/// <para/>
/// The model also owns the session overrides that keep the UI honest after
/// file operations: a mod installed or deleted from the manager is not yet
/// reflected by the registry (which is loaded once per launch), so
/// <see cref="MarkInstalled"/> / <see cref="MarkRemoved"/> adjust the view and
/// the restart banner tells the player when the change becomes real.
/// </summary>
public class ModManagerModel
{
/// <summary>Id of the mod that renders this UI; it can never be disabled or removed.</summary>
public const string SelfModId = "mod_manager";
private readonly List<ModCardData> _items = new();
private readonly HashSet<string> _removed = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _installed = new(StringComparer.OrdinalIgnoreCase);
/// <summary>All cards, unsorted and unfiltered; rebuild with <see cref="Reload"/>.</summary>
public IReadOnlyList<ModCardData> Items => _items;
/// <summary>
/// Rebuilds the card list from the current registry, disabled store and
/// catalog cache. Safe to call on every action completion and after a
/// catalog refresh.
/// </summary>
public void Reload()
{
_items.Clear();
var disabled = DisabledModsStore.Load();
var variantsById = BuildCatalogVariants();
var localIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ordered = ModRegistry.Instance.GetOrderedModIds(filterDisabled: false);
foreach (var id in ordered)
{
if (_removed.Contains(id))
continue;
if (!ModRegistry.Instance.AllDiscoveredMods.TryGetValue(id, out var info) || info is not ModInfo modInfo)
continue;
localIds.Add(id);
var isDisabled = disabled.Contains(id);
if (variantsById.TryGetValue(id, out var variants))
{
foreach (var variant in variants)
_items.Add(BuildLocalCard(id, modInfo, isDisabled, variant));
}
else
{
_items.Add(BuildLocalCard(id, modInfo, isDisabled, null));
}
}
foreach (var pair in variantsById)
{
if (localIds.Contains(pair.Key) || _removed.Contains(pair.Key))
continue;
foreach (var variant in pair.Value)
_items.Add(BuildRemoteCard(variant));
}
}
/// <summary>Remembers that a mod was deleted this session so it stays hidden until restart.</summary>
public void MarkRemoved(string modId)
{
_removed.Add(modId);
_installed.Remove(modId);
}
/// <summary>Remembers a successful install so the card shows as installed before restart.</summary>
public void MarkInstalled(string modId, string version)
{
_removed.Remove(modId);
_installed[modId] = version ?? string.Empty;
}
/// <summary>
/// Applies an enable/disable toggle with the same cascade as the legacy
/// menu: disabling a mod also disables its dependents, enabling one also
/// re-enables the dependencies it needs.
/// </summary>
public void SetEnabled(string modId, bool enabled)
{
var disabled = DisabledModsStore.Load();
if (enabled)
{
disabled.Remove(modId);
var info = ModRegistry.Instance.GetMod(modId) as ModInfo;
var dependencies = info?.Manifest?.DependencyIds;
if (dependencies != null)
{
foreach (var dependency in dependencies)
disabled.Remove(dependency);
}
}
else
{
disabled.Add(modId);
CollectDependents(modId, disabled);
}
DisabledModsStore.Save(disabled);
}
/// <summary>Category buckets for the sidebar, respecting every filter except the category set.</summary>
public Dictionary<CategoryType, int> CountCategories(ModFilterState filter)
{
var counts = new Dictionary<CategoryType, int>();
foreach (var item in _items)
{
if (!filter.MatchesExceptCategory(item))
continue;
foreach (var category in ModCategoryNames.Ordered)
{
if ((item.Category & category) == 0)
continue;
counts[category] = counts.GetValueOrDefault(category) + 1;
}
}
return counts;
}
/// <summary>Status buckets for the sidebar, respecting every filter except the status set.</summary>
public Dictionary<ModItemStatus, int> CountStatuses(ModFilterState filter)
{
var counts = new Dictionary<ModItemStatus, int>();
foreach (var item in _items)
{
if (!filter.MatchesExceptStatus(item))
continue;
counts[item.Status] = counts.GetValueOrDefault(item.Status) + 1;
}
return counts;
}
/// <summary>
/// Indexes catalog entries by mod id. Normally one entry per id, but a
/// variant conflict (same id/version/title, different content across
/// catalogs) produces several entries that the UI shows as separate mods.
/// </summary>
private Dictionary<string, List<CatalogExportEntry>> BuildCatalogVariants()
{
var index = new Dictionary<string, List<CatalogExportEntry>>(StringComparer.OrdinalIgnoreCase);
var entries = CatalogService.CachedEntries;
if (entries == null)
return index;
foreach (var entry in entries)
{
if (string.IsNullOrEmpty(entry?.Id))
continue;
if (!index.TryGetValue(entry.Id, out var list))
{
list = new List<CatalogExportEntry>();
index[entry.Id] = list;
}
list.Add(entry);
}
return index;
}
private ModCardData BuildLocalCard(string id, ModInfo modInfo, bool disabled, CatalogExportEntry catalog)
{
var data = new ModCardData
{
Id = id,
Title = modInfo.DisplayTitle,
Author = modInfo.Author,
Description = modInfo.LocalizedDescription,
LocalVersion = modInfo.Version,
Category = modInfo.Manifest?.Category ?? CategoryType.Other,
LocalInfo = modInfo,
Catalog = catalog,
IsInstalled = true,
IsDisabled = disabled,
IsDeletable = ModUninstaller.CanUninstall(modInfo),
IsProtected = string.Equals(id, SelfModId, StringComparison.OrdinalIgnoreCase),
};
if (_installed.TryGetValue(id, out var installedVersion))
data.LocalVersion = installedVersion;
ApplyDownloads(data, catalog);
data.RecomputeStatus();
return data;
}
private ModCardData BuildRemoteCard(CatalogExportEntry catalog)
{
var locale = GetCatalogLocale();
var data = new ModCardData
{
Id = catalog.Id,
Title = catalog.GetLocalizedName(locale),
Author = catalog.Author,
Description = catalog.GetLocalizedDescription(locale),
Category = catalog.Category,
Catalog = catalog,
IsInstalled = false,
};
if (_installed.TryGetValue(catalog.Id, out var installedVersion))
{
data.IsInstalled = true;
data.LocalVersion = installedVersion;
}
ApplyDownloads(data, catalog);
data.RecomputeStatus();
return data;
}
private static void ApplyDownloads(ModCardData data, CatalogExportEntry catalog)
{
data.Downloads = catalog?.Downloads ?? new List<CatalogEntryDownload>();
data.SelectedDownload = data.Downloads.Count > 0 ? data.Downloads[0] : null;
}
private static string GetCatalogLocale()
{
var locale = TranslationServer.GetLocale();
return string.IsNullOrEmpty(locale) ? "en" : locale;
}
private static void CollectDependents(string modId, HashSet<string> disabled)
{
var allMods = ModRegistry.Instance.AllDiscoveredMods;
foreach (var pair in allMods)
{
if (disabled.Contains(pair.Key) || pair.Value is not ModInfo info)
continue;
var dependencies = info.Manifest?.DependencyIds;
if (dependencies != null && dependencies.Contains(modId))
{
disabled.Add(pair.Key);
CollectDependents(pair.Key, disabled);
}
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
namespace Mods.ModManager
{
/// <summary>
/// Read-only cross-mod lookups used by the detail page: direct dependencies,
/// reverse dependencies (which mods depend on this one) and declared
/// conflicts. Prefers local manifest data and falls back to the catalog entry
/// for mods that are not installed.
/// </summary>
public static class ModQueries
{
/// <summary>Dependency strings (<c>core&gt;=1</c> format) of a mod, whatever source knows it.</summary>
public static List<string> GetDependencies(ModCardData data)
{
var manifestDeps = data?.LocalInfo?.Manifest?.DependencyIds;
if (manifestDeps != null && manifestDeps.Count > 0)
return manifestDeps;
return data?.Catalog?.Depends ?? new List<string>();
}
/// <summary>Ids of installed mods whose manifests depend on <paramref name="modId"/>.</summary>
public static List<string> GetDependents(string modId)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
var allMods = ModRegistry.Instance.AllDiscoveredMods;
if (allMods == null)
return result;
foreach (var pair in allMods)
{
if (pair.Key == modId || pair.Value is not ModInfo info)
continue;
var deps = info.Manifest?.DependencyIds;
if (deps != null && deps.Contains(modId))
result.Add(pair.Key);
}
return result;
}
/// <summary>Declared mutually exclusive mod ids (manifest first, catalog fallback).</summary>
public static List<string> GetConflicts(ModCardData data)
{
var manifestConflicts = data?.LocalInfo?.Manifest?.ConflictsWith;
if (manifestConflicts != null && manifestConflicts.Count > 0)
return manifestConflicts;
return data?.Catalog?.ConflictsWith ?? new List<string>();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Mods.ModManager
{
/// <summary>Ordering mode of the mod grid; grouping by category is a separate toggle.</summary>
public enum ModSortMode
{
/// <summary>Alphabetical by display title.</summary>
Name,
/// <summary>Alphabetical by author, then title.</summary>
Author,
/// <summary>By install/update state (installed, updates, catalog-only).</summary>
Status,
}
}