Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/knowledgebase/cli-selection-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<<modDirectory>>/*…/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.
Expand Down
54 changes: 54 additions & 0 deletions scripts/agents/k2_merged_roundtrip_download_install_smoke.sh
Original file line number Diff line number Diff line change
@@ -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."
3 changes: 2 additions & 1 deletion src/ModSync.Core/ModComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down
64 changes: 63 additions & 1 deletion src/ModSync.Core/Parsing/DraftInstructionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

using JetBrains.Annotations;

Expand Down Expand Up @@ -49,6 +50,14 @@ public static class DraftInstructionService
[NotNull] private const string KotorDirectoryPlaceholder = "<<kotorDirectory>>";
[NotNull] private const string LegacyGameDirectoryPlaceholder = "<<gameDirectory>>";

/// <summary>
/// Matches KOTOR loose-file names mentioned in guide prose (e.g. 153sion.dlg).
/// </summary>
[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);

/// <summary>
/// Generates draft instructions for every component that has natural-language Directions prose
/// but no authored instructions. Components that already have instructions are never touched.
Expand Down Expand Up @@ -167,10 +176,15 @@ private static Instruction TryCreateMethodFallbackInstruction([NotNull] ModCompo
? KotorDirectoryPlaceholder + @"\Movies"
: KotorDirectoryPlaceholder + @"\Override";

Match fileMatch = s_looseFileNamePattern.Match(directions);
List<string> sources = fileMatch.Success
? BuildLooseFileMoveSources(fileMatch.Groups[1].Value)
: new List<string> { ModDirectoryPlaceholder + @"\*" };

return new Instruction
{
Action = Instruction.ActionType.Move,
Source = new List<string> { ModDirectoryPlaceholder + @"\*" },
Source = sources,
Destination = destination,
Overwrite = directions.IndexOf("do not overwrite", StringComparison.OrdinalIgnoreCase) < 0
&& directions.IndexOf("don't overwrite", StringComparison.OrdinalIgnoreCase) < 0,
Expand Down Expand Up @@ -207,6 +221,54 @@ private static bool LooksLikeLooseFileInstall([NotNull] string text)
|| lower.IndexOf("loose file", StringComparison.Ordinal) >= 0;
}

/// <summary>
/// Builds sandboxed Move sources for a named loose file, including nested paths after Extract.
/// </summary>
[NotNull]
internal static List<string> 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<string> { $"{ModDirectoryPlaceholder}\\{trimmed}" };

for (int depth = 1; depth <= maxNestedDepth; depth++)
{
sources.Add($"{ModDirectoryPlaceholder}\\{string.Join("\\", Enumerable.Repeat("*", depth))}\\{trimmed}");
}

return sources;
}

/// <summary>
/// Expands existing Move sources with nested mod-directory search paths for a named file.
/// </summary>
[NotNull]
internal static List<string> ExpandLooseFileMoveSources(
[CanBeNull] IEnumerable<string> existingSources,
[NotNull] string fileName,
int maxNestedDepth = 3)
{
var merged = new List<string>();
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;
}

/// <summary>
/// Normalizes placeholders and enforces the path-sandboxing rules on a draft instruction:
/// every Source entry and any non-empty Destination must start with
Expand Down
8 changes: 4 additions & 4 deletions src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1069,8 +1069,9 @@ private static List<string> ExtractSources([NotNull] string sourceText, [NotNull
List<string> files = ParseFileList(filesText);
foreach (string file in files)
{
sources.Add($"<<modDirectory>>\\{file}");
sources.AddRange(DraftInstructionService.BuildLooseFileMoveSources(file));
}

if (sources.Count > 0)
{
return sources;
Expand All @@ -1091,8 +1092,7 @@ private static List<string> ExtractSources([NotNull] string sourceText, [NotNull
if (singleFileMatch.Success)
{
string file = singleFileMatch.Groups["file"].Value.Trim('"', '\'');
sources.Add($"<<modDirectory>>\\{file}");
return sources;
return DraftInstructionService.BuildLooseFileMoveSources(file);
}

// === Fallback: treat as folder or pattern ===
Expand All @@ -1104,7 +1104,7 @@ private static List<string> ExtractSources([NotNull] string sourceText, [NotNull
// Check if it looks like a file (has extension)
if (Path.HasExtension(cleaned))
{
sources.Add($"<<modDirectory>>\\{cleaned}");
return DraftInstructionService.BuildLooseFileMoveSources(cleaned);
}
// Check if it has wildcards
else if (cleaned.Contains("*") || cleaned.Contains("?"))
Expand Down
12 changes: 10 additions & 2 deletions src/ModSync.Core/Services/ModComponentSerializationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1747,10 +1747,18 @@ public static ModComponent DeserializeComponent([NotNull] IDictionary<string, ob
foreach (KeyValuePair<string, Dictionary<string, bool?>> kvp in deserializedFilenames)
{
string url = kvp.Key;
Dictionary<string, bool?> filenames = kvp.Value;
Dictionary<string, bool?> filenames = kvp.Value ?? new Dictionary<string, bool?>(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<string, bool?>(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.
Expand Down
75 changes: 75 additions & 0 deletions src/ModSync.Tests/GuideIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DraftInstructionResult> 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(@"<<modDirectory>>\153sion.dlg"));
Assert.That(component.Instructions[0].Source, Does.Contain(@"<<modDirectory>>\*\153sion.dlg"));
Assert.That(component.Instructions[0].Destination, Is.EqualTo(@"<<kotorDirectory>>\Override"));
});
}

[Test]
public void K2FullGuideFixture_IngestedMultiModInstallSmoke_InstallsViaModSelect()
{
Expand Down
Loading