diff --git a/docs/knowledgebase/cli-selection-semantics.md b/docs/knowledgebase/cli-selection-semantics.md index c8a845e8..6d74af5a 100644 --- a/docs/knowledgebase/cli-selection-semantics.md +++ b/docs/knowledgebase/cli-selection-semantics.md @@ -28,6 +28,14 @@ Example (merged neocities K2 + golden TOML): ./scripts/agents/k2_ingested_merge_download_smoke.sh --download-mod "Silent Sion Restoration" ``` +Full round-trip (golden URLs + ingested NLP → download → extract → Override install; network + `mod-builds`): + +```bash +./scripts/agents/k2_merged_roundtrip_download_install_smoke.sh +``` + +Wraps `K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning`. NLP Move drafts for single loose files include nested `<>/*…/filename` sources so post-extract paths resolve. + ## `validate` without `--select` (default) Validates **all loaded components**. TOML `IsSelected` is **not** used unless `--use-file-selection` or `--select` is provided. diff --git a/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh b/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh new file mode 100755 index 00000000..a7c9828a --- /dev/null +++ b/scripts/agents/k2_merged_roundtrip_download_install_smoke.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Full K2 neocities round-trip smoke: golden URLs + ingested NLP → download → extract → Override install. +# Wraps the LongRunning integration test (network required; mod-builds at repo root). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +GOLDEN="${GOLDEN:-$ROOT/mod-builds/TOMLs/KOTOR2_Full.toml}" +FILTER="${FILTER:-FullyQualifiedName~K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning}" + +usage() { + cat <<'EOF' +Usage: k2_merged_roundtrip_download_install_smoke.sh [options] + +Runs the K2 Full round-trip LongRunning test: merge neocities fixture with golden TOML, +overlay ingested NLP instructions for Silent Sion, download the archive, extract, and +install 153sion.dlg to Override. + +Requires: + - mod-builds cloned at ./mod-builds (KOTOR2_Full.toml) + - network access to Deadly Stream download URLs + +Options: + --golden PATH Golden TOML path (default: ./mod-builds/TOMLs/KOTOR2_Full.toml) + -h, --help Show this help + +Environment: + GOLDEN, FILTER (dotnet test --filter override) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --golden) GOLDEN="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ ! -f "$GOLDEN" ]]; then + echo "Golden TOML not found: $GOLDEN" >&2 + echo "Clone mod-builds: git clone https://github.com/th3w1zard1/mod-builds ./mod-builds" >&2 + exit 1 +fi + +export DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}" +export PATH="$DOTNET_ROOT:$PATH" + +echo "==> dotnet test (round-trip download + install)" +dotnet test "$ROOT/src/ModSync.Tests/ModSync.Tests.csproj" -c Debug -f net9.0 \ + --filter "$FILTER" + +echo "Round-trip download+install smoke passed." diff --git a/src/ModSync.Core/ModComponent.cs b/src/ModSync.Core/ModComponent.cs index f1502272..23ff0379 100644 --- a/src/ModSync.Core/ModComponent.cs +++ b/src/ModSync.Core/ModComponent.cs @@ -1393,7 +1393,8 @@ [NotNull] Services.FileSystem.IFileSystemProvider fileSystemProvider // Check if this resource contains the missing file foreach (var file in resource.Value.Files) { - if (file.Value == true && ResourceRegistryEntryMatchesMissingSource(file.Key, missingFile)) + if (ResourceRegistryEntryMatchesMissingSource(file.Key, missingFile) + && (file.Value == true || file.Value == null)) { if (!archiveMatches.ContainsKey(missingFile)) { diff --git a/src/ModSync.Core/Parsing/DraftInstructionService.cs b/src/ModSync.Core/Parsing/DraftInstructionService.cs index d0805c63..ea9fd0d0 100644 --- a/src/ModSync.Core/Parsing/DraftInstructionService.cs +++ b/src/ModSync.Core/Parsing/DraftInstructionService.cs @@ -7,6 +7,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using JetBrains.Annotations; @@ -49,6 +50,14 @@ public static class DraftInstructionService [NotNull] private const string KotorDirectoryPlaceholder = "<>"; [NotNull] private const string LegacyGameDirectoryPlaceholder = "<>"; + /// + /// Matches KOTOR loose-file names mentioned in guide prose (e.g. 153sion.dlg). + /// + [NotNull] + private static readonly Regex s_looseFileNamePattern = new Regex( + @"\b([\w\.\-]+\.(?:dlg|2da|tga|tpc|utc|uti|utm|utd|ute|uts|utw|ssf|bwm|mdl|mdx|txi|lip|lyt|vis|pth|ncs|gui))\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// /// Generates draft instructions for every component that has natural-language Directions prose /// but no authored instructions. Components that already have instructions are never touched. @@ -167,10 +176,15 @@ private static Instruction TryCreateMethodFallbackInstruction([NotNull] ModCompo ? KotorDirectoryPlaceholder + @"\Movies" : KotorDirectoryPlaceholder + @"\Override"; + Match fileMatch = s_looseFileNamePattern.Match(directions); + List sources = fileMatch.Success + ? BuildLooseFileMoveSources(fileMatch.Groups[1].Value) + : new List { ModDirectoryPlaceholder + @"\*" }; + return new Instruction { Action = Instruction.ActionType.Move, - Source = new List { ModDirectoryPlaceholder + @"\*" }, + Source = sources, Destination = destination, Overwrite = directions.IndexOf("do not overwrite", StringComparison.OrdinalIgnoreCase) < 0 && directions.IndexOf("don't overwrite", StringComparison.OrdinalIgnoreCase) < 0, @@ -207,6 +221,54 @@ private static bool LooksLikeLooseFileInstall([NotNull] string text) || lower.IndexOf("loose file", StringComparison.Ordinal) >= 0; } + /// + /// Builds sandboxed Move sources for a named loose file, including nested paths after Extract. + /// + [NotNull] + internal static List BuildLooseFileMoveSources([NotNull] string fileName, int maxNestedDepth = 3) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("File name is required.", nameof(fileName)); + } + + string trimmed = fileName.Trim().Trim('"', '\''); + var sources = new List { $"{ModDirectoryPlaceholder}\\{trimmed}" }; + + for (int depth = 1; depth <= maxNestedDepth; depth++) + { + sources.Add($"{ModDirectoryPlaceholder}\\{string.Join("\\", Enumerable.Repeat("*", depth))}\\{trimmed}"); + } + + return sources; + } + + /// + /// Expands existing Move sources with nested mod-directory search paths for a named file. + /// + [NotNull] + internal static List ExpandLooseFileMoveSources( + [CanBeNull] IEnumerable existingSources, + [NotNull] string fileName, + int maxNestedDepth = 3) + { + var merged = new List(); + if (existingSources != null) + { + merged.AddRange(existingSources.Where(source => !string.IsNullOrWhiteSpace(source))); + } + + foreach (string source in BuildLooseFileMoveSources(fileName, maxNestedDepth)) + { + if (!merged.Any(existing => string.Equals(existing, source, StringComparison.OrdinalIgnoreCase))) + { + merged.Add(source); + } + } + + return merged; + } + /// /// Normalizes placeholders and enforces the path-sandboxing rules on a draft instruction: /// every Source entry and any non-empty Destination must start with diff --git a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs index e6d6bfee..2fdbc453 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -1069,8 +1069,9 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull List files = ParseFileList(filesText); foreach (string file in files) { - sources.Add($"<>\\{file}"); + sources.AddRange(DraftInstructionService.BuildLooseFileMoveSources(file)); } + if (sources.Count > 0) { return sources; @@ -1091,8 +1092,7 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull if (singleFileMatch.Success) { string file = singleFileMatch.Groups["file"].Value.Trim('"', '\''); - sources.Add($"<>\\{file}"); - return sources; + return DraftInstructionService.BuildLooseFileMoveSources(file); } // === Fallback: treat as folder or pattern === @@ -1104,7 +1104,7 @@ private static List ExtractSources([NotNull] string sourceText, [NotNull // Check if it looks like a file (has extension) if (Path.HasExtension(cleaned)) { - sources.Add($"<>\\{cleaned}"); + return DraftInstructionService.BuildLooseFileMoveSources(cleaned); } // Check if it has wildcards else if (cleaned.Contains("*") || cleaned.Contains("?")) diff --git a/src/ModSync.Core/Services/ModComponentSerializationService.cs b/src/ModSync.Core/Services/ModComponentSerializationService.cs index 1c805572..5be2d6f4 100644 --- a/src/ModSync.Core/Services/ModComponentSerializationService.cs +++ b/src/ModSync.Core/Services/ModComponentSerializationService.cs @@ -1747,10 +1747,18 @@ public static ModComponent DeserializeComponent([NotNull] IDictionary> kvp in deserializedFilenames) { string url = kvp.Key; - Dictionary filenames = kvp.Value; + Dictionary filenames = kvp.Value ?? new Dictionary(StringComparer.OrdinalIgnoreCase); // Check if ResourceRegistry already has an entry for this URL (keyed by URL directly) - bool urlExistsInRegistry = registryDict.ContainsKey(url); + bool urlExistsInRegistry = registryDict.TryGetValue(url, out ResourceMetadata existingMeta); + + if (urlExistsInRegistry && filenames.Count > 0 && + (existingMeta.Files == null || existingMeta.Files.Count == 0)) + { + existingMeta.Files = new Dictionary(filenames, StringComparer.OrdinalIgnoreCase); + Logger.LogVerbose($"Merged ModLinkFilenames into existing ResourceRegistry entry for URL: {url}"); + continue; + } // Empty ModLinkFilenames tables ({ }) still denote a download source; populate registry so // download/cache and FileValidation see the URL. diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index d0405acd..17730361 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -628,6 +628,81 @@ public void K2FullGuideFixture_IngestedSilentSion_InstallsViaCli() }); } + [Test] + public void K2FullGuideFixture_IngestedSilentSion_InstallsFromNestedExtractFolder() + { + string fixturePath = Path.Combine(ResolveRepoRoot(), "src", "ModSync.Tests", "Fixtures", "k2_full_guide.md"); + Assert.That(File.Exists(fixturePath), Is.True); + + string kotorDir = Path.Combine(_testDirectory, "KOTOR"); + string modDir = Path.Combine(_testDirectory, "Mods"); + Directory.CreateDirectory(Path.Combine(kotorDir, "Override")); + File.WriteAllText(Path.Combine(kotorDir, "swkotor.exe"), "fake exe"); + File.WriteAllText(Path.Combine(kotorDir, "dialog.tlk"), "fake dialog"); + Directory.CreateDirectory(modDir); + + string nestedDlg = Path.Combine(modDir, "Silent Sion Restoration", "153sion.dlg"); + Directory.CreateDirectory(Path.GetDirectoryName(nestedDlg)!); + File.WriteAllText(nestedDlg, "silent sion dlg"); + + string ingestedToml = Path.Combine(_testDirectory, "k2_full_ingested.toml"); + int convertExit = ModBuildConverter.Run(new[] + { + "convert", + "--input", fixturePath, + "-f", "toml", + "--parse-directions", + "-o", ingestedToml, + "--plaintext", + }); + + Assert.That(convertExit, Is.EqualTo(0)); + + StripDependenciesForInstallSmoke(ingestedToml, "Silent Sion Restoration"); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", ingestedToml, + "--game-dir", kotorDir, + "--source-dir", modDir, + "--select", "mod:Silent Sion Restoration", + "--skip-validation", + "--best-effort", + "-y", + }); + + Assert.Multiple(() => + { + Assert.That(installExit, Is.EqualTo(0)); + Assert.That(File.Exists(Path.Combine(kotorDir, "Override", "153sion.dlg")), Is.True, + "Move draft should find 153sion.dlg after extract-style nesting"); + }); + } + + [Test] + public void DraftInstructions_SilentSionMoveJust_IncludesNestedSearchPaths() + { + ModComponent component = new ModComponent + { + Name = "Silent Sion Restoration", + InstallationMethod = "Loose-File", + Directions = "Move just 153sion.dlg to the override.", + }; + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.Multiple(() => + { + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions, Has.Count.EqualTo(1)); + Assert.That(component.Instructions[0].Action, Is.EqualTo(Instruction.ActionType.Move)); + Assert.That(component.Instructions[0].Source, Does.Contain(@"<>\153sion.dlg")); + Assert.That(component.Instructions[0].Source, Does.Contain(@"<>\*\153sion.dlg")); + Assert.That(component.Instructions[0].Destination, Is.EqualTo(@"<>\Override")); + }); + } + [Test] public void K2FullGuideFixture_IngestedMultiModInstallSmoke_InstallsViaModSelect() { diff --git a/src/ModSync.Tests/K2FullGuideDownloadTests.cs b/src/ModSync.Tests/K2FullGuideDownloadTests.cs index 7d58d148..6d18e45b 100644 --- a/src/ModSync.Tests/K2FullGuideDownloadTests.cs +++ b/src/ModSync.Tests/K2FullGuideDownloadTests.cs @@ -71,6 +71,7 @@ public void TearDown() } [Test] + [Timeout(600_000)] public void K2FullGuideFixture_MergedSilentSion_DownloadsViaInstallSelect_LongRunning() { (string fixturePath, string goldenTomlPath) = ResolveInputs(); @@ -119,6 +120,122 @@ public void K2FullGuideFixture_MergedSilentSion_DownloadsViaInstallSelect_LongRu }); } + [Test] + [Timeout(600_000)] + public void K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string roundTripToml = Path.Combine(_testDirectory, "k2_roundtrip.toml"); + + K2FullGuideRoundTripHelper.WriteRoundTripToml( + fixturePath, + goldenTomlPath, + roundTripToml, + SilentSionModName); + + int installExit = ModBuildConverter.Run(new[] + { + "install", + "--input", roundTripToml, + "--game-dir", _kotorDirectory, + "--source-dir", _modDirectory, + "--select", "mod:" + SilentSionModName, + "--download", + "--skip-validation", + "--best-effort", + "-y", + }); + + string installedDlg = Path.Combine(_kotorDirectory, "Override", "153sion.dlg"); + string[] modWorkspaceFiles = Directory.Exists(_modDirectory) + ? Directory.GetFiles(_modDirectory, "*", SearchOption.AllDirectories) + : Array.Empty(); + + Assert.Multiple(() => + { + Assert.That(installExit, Is.EqualTo(0), + "round-trip install --download should complete for Silent Sion (best-effort)"); + Assert.That( + File.Exists(Path.Combine(_modDirectory, SilentSionArchive)) + || modWorkspaceFiles.Any(path => path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)), + Is.True, + "Silent Sion archive should be present in the mod workspace after download"); + Assert.That(File.Exists(installedDlg), Is.True, + "153sion.dlg should be installed to Override after download, extract, and move"); + }); + } + + [Test] + public void MergedSilentSion_ResourceRegistryListsArchiveFilenames() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string mergedToml = Path.Combine(_testDirectory, "k2_merged.toml"); + + int mergeExit = ModBuildConverter.Run(new[] + { + "merge", + "--existing", goldenTomlPath, + "--incoming", fixturePath, + "--use-existing-order", + "--prefer-existing-instructions", + "--prefer-existing-options", + "--prefer-existing-modlinks", + "-f", "toml", + "-o", mergedToml, + "--plaintext", + }); + + Assert.That(mergeExit, Is.EqualTo(0)); + + ModComponent silentSion = FileLoadingService.LoadFromFile(mergedToml) + .First(c => c.Name.IndexOf(SilentSionModName, StringComparison.OrdinalIgnoreCase) >= 0); + + int registryFileCount = silentSion.ResourceRegistry?.Sum(entry => entry.Value.Files?.Count ?? 0) ?? 0; + + Assert.That(registryFileCount, Is.GreaterThan(0), + "Merged Silent Sion ResourceRegistry entries should list archive filenames for round-trip Extract injection"); + } + + [Test] + [Timeout(600_000)] + public void RoundTripHelper_OverlaySilentSion_AddsExtractForRegistryArchive() + { + (string fixturePath, string goldenTomlPath) = ResolveInputs(); + string roundTripToml = Path.Combine(_testDirectory, "k2_roundtrip.toml"); + + K2FullGuideRoundTripHelper.WriteRoundTripToml( + fixturePath, + goldenTomlPath, + roundTripToml, + SilentSionModName); + + List components = FileLoadingService.LoadFromFile(roundTripToml).ToList(); + ModComponent silentSion = components.First(c => + c.Name.IndexOf(SilentSionModName, StringComparison.OrdinalIgnoreCase) >= 0); + + Assert.Multiple(() => + { + Assert.That(silentSion.ResourceRegistry?.Count, Is.GreaterThan(0)); + Assert.That( + silentSion.Instructions.Any(i => + i.Action == Instruction.ActionType.Extract + && i.Source.Any(s => s.IndexOf("Silent Sion Restoration.zip", StringComparison.OrdinalIgnoreCase) >= 0)), + Is.True, + "Round-trip overlay should prepend Extract for the Silent Sion archive"); + Assert.That( + silentSion.Instructions.Any(i => i.Action == Instruction.ActionType.Move), + Is.True, + "Round-trip overlay should keep ingested Move draft"); + Instruction moveInstruction = silentSion.Instructions.First(i => i.Action == Instruction.ActionType.Move); + Assert.That( + moveInstruction.Source.Any(source => + source.IndexOf("153sion.dlg", StringComparison.OrdinalIgnoreCase) >= 0 + && source.IndexOf('*', StringComparison.Ordinal) >= 0), + Is.True, + "Round-trip Move should search nested extract folders for 153sion.dlg"); + }); + } + private static (string fixturePath, string goldenTomlPath) ResolveInputs() { string repoRoot = ResolveRepoRoot(); diff --git a/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs new file mode 100644 index 00000000..bd1550c2 --- /dev/null +++ b/src/ModSync.Tests/K2FullGuideRoundTripHelper.cs @@ -0,0 +1,268 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using ModSync.Core; +using ModSync.Core.CLI; +using ModSync.Core.Parsing; +using ModSync.Core.Services; + +namespace ModSync.Tests +{ + /// + /// Builds K2 neocities round-trip TOMLs: golden download URLs + ingested NLP install instructions. + /// + internal static class K2FullGuideRoundTripHelper + { + internal static void WriteRoundTripToml( + string fixturePath, + string goldenTomlPath, + string outputTomlPath, + params string[] overlayInstructionModNames) + { + if (overlayInstructionModNames is null || overlayInstructionModNames.Length == 0) + { + throw new ArgumentException("At least one mod name is required for instruction overlay.", nameof(overlayInstructionModNames)); + } + + string tempDir = Path.Combine(Path.GetTempPath(), "ModSync_K2RoundTrip_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + + try + { + string mergedToml = Path.Combine(tempDir, "merged.toml"); + string ingestedToml = Path.Combine(tempDir, "ingested.toml"); + + int mergeExit = ModBuildConverter.Run(new[] + { + "merge", + "--existing", goldenTomlPath, + "--incoming", fixturePath, + "--use-existing-order", + "--prefer-existing-instructions", + "--prefer-existing-options", + "--prefer-existing-modlinks", + "-f", "toml", + "-o", mergedToml, + "--plaintext", + }); + + if (mergeExit != 0) + { + throw new InvalidOperationException($"merge failed with exit code {mergeExit}"); + } + + int convertExit = ModBuildConverter.Run(new[] + { + "convert", + "--input", fixturePath, + "-f", "toml", + "--parse-directions", + "-o", ingestedToml, + "--plaintext", + }); + + if (convertExit != 0) + { + throw new InvalidOperationException($"convert --parse-directions failed with exit code {convertExit}"); + } + + List merged = FileLoadingService.LoadFromFile(mergedToml).ToList(); + List ingested = FileLoadingService.LoadFromFile(ingestedToml).ToList(); + + OverlayIngestedInstructions(merged, ingested, overlayInstructionModNames); + FileLoadingService.SaveToFile(merged, outputTomlPath); + } + finally + { + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + } + + internal static void OverlayIngestedInstructions( + List merged, + IReadOnlyList ingested, + params string[] modNameFragments) + { + var ingestedByName = ingested + .GroupBy(c => c.Name?.Trim() ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (ModComponent component in merged) + { + if (!modNameFragments.Any(fragment => + component.Name.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0)) + { + continue; + } + + if (!ingestedByName.TryGetValue(component.Name.Trim(), out ModComponent ingestedComponent)) + { + ingestedComponent = ingested.FirstOrDefault(candidate => + string.Equals(candidate.Name, component.Name, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(candidate.Name) + && candidate.Name.IndexOf(component.Name, StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrEmpty(component.Name) + && component.Name.IndexOf(candidate.Name, StringComparison.OrdinalIgnoreCase) >= 0)); + } + + if (ingestedComponent is null) + { + continue; + } + + component.Instructions.Clear(); + foreach (Instruction instruction in ingestedComponent.Instructions) + { + component.Instructions.Add(instruction); + } + + component.Options.Clear(); + foreach (Option option in ingestedComponent.Options) + { + component.Options.Add(option); + } + + component.InstallationWarning = ingestedComponent.InstallationWarning; + component.Dependencies.Clear(); + component.Restrictions.Clear(); + + EnsureExtractInstructionsFromRegistry(component); + RefineMoveInstructionsFromDirections(component); + } + } + + /// + /// NLP Move-only drafts do not satisfy download auto-discover (which keys off Extract patterns). + /// Prepend Extract steps for registry archives that match this component's name. + /// + private static void EnsureExtractInstructionsFromRegistry(ModComponent component) + { + if (component.ResourceRegistry is null || component.ResourceRegistry.Count == 0) + { + return; + } + + if (component.Instructions.Any(i => i.Action == Instruction.ActionType.Extract)) + { + return; + } + + var existingExtractSources = new HashSet( + component.Instructions + .Where(i => i.Action == Instruction.ActionType.Extract) + .SelectMany(i => i.Source ?? Enumerable.Empty()), + StringComparer.OrdinalIgnoreCase); + + var archiveNames = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair resource in component.ResourceRegistry) + { + foreach (string filename in resource.Value.Files.Keys) + { + if (!string.IsNullOrWhiteSpace(filename) && ShouldIncludeRegistryArchive(component.Name, filename)) + { + archiveNames.Add(filename); + } + } + } + + int insertAt = 0; + foreach (string archiveName in archiveNames) + { + string source = $"<>/{archiveName}"; + if (existingExtractSources.Contains(source)) + { + continue; + } + + component.Instructions.Insert(insertAt++, new Instruction + { + Action = Instruction.ActionType.Extract, + Source = new List { source }, + }); + } + + if (insertAt == 0 + && component.ResourceRegistry.Count > 0 + && string.Equals(component.InstallationMethod, "Loose-File", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(component.Name)) + { + string fallbackArchive = component.Name.Trim() + ".zip"; + string source = $"<>/{fallbackArchive}"; + if (!existingExtractSources.Contains(source)) + { + component.Instructions.Insert(0, new Instruction + { + Action = Instruction.ActionType.Extract, + Source = new List { source }, + }); + } + } + } + + private static void RefineMoveInstructionsFromDirections(ModComponent component) + { + Instruction move = component.Instructions.FirstOrDefault(i => i.Action == Instruction.ActionType.Move); + if (move is null || string.IsNullOrWhiteSpace(component.Directions)) + { + return; + } + + Match fileMatch = Regex.Match( + component.Directions, + @"\b([\w\.\-]+\.(?:dlg|2da|tga|tpc|utc|uti|utm|utd|ute|uts|utw|ssf|bwm|mdl|mdx|txi|lip|lyt|vis|pth|ncs|gui))\b", + RegexOptions.IgnoreCase); + + if (!fileMatch.Success) + { + return; + } + + string fileName = fileMatch.Groups[1].Value; + move.Source = DraftInstructionService.ExpandLooseFileMoveSources(move.Source, fileName); + } + + private static bool ShouldIncludeRegistryArchive(string modName, string archiveFileName) + { + if (string.IsNullOrWhiteSpace(modName) || string.IsNullOrWhiteSpace(archiveFileName)) + { + return false; + } + + string stem = Path.GetFileNameWithoutExtension(archiveFileName); + if (stem.IndexOf(modName, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + string[] tokens = modName + .Split(new[] { ' ', '-', '_', '(', ')' }, StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.Length > 3) + .ToArray(); + + if (tokens.Length == 0) + { + return false; + } + + int hits = tokens.Count(token => stem.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0); + return hits >= Math.Min(2, tokens.Length); + } + } +}