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
2 changes: 1 addition & 1 deletion docs/knowledgebase/cli-selection-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Full round-trip (golden URLs + ingested NLP → download → extract → Overrid
./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.
Wraps `K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning` by default; set `MOD="Prestige Class Saving Throw Fixes"` (or `FILTER=…RoundTripPrestige…`) for the Prestige Class folder-Move round-trip. NLP Move drafts for single loose files include nested `<<modDirectory>>/*…/filename` sources so post-extract paths resolve. Folder advisories (e.g. Prestige Class “only install the Jedi Master/Sith Lord fixes”) add nested folder wildcards plus slug/fuzzy variants (`jedimaster_sithlord fixes`) via `DraftInstructionService.BuildFolderMoveSources`.

## `validate` without `--select` (default)

Expand Down
20 changes: 16 additions & 4 deletions scripts/agents/k2_merged_roundtrip_download_install_smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,48 @@ cd "$ROOT"

GOLDEN="${GOLDEN:-$ROOT/mod-builds/TOMLs/KOTOR2_Full.toml}"
FILTER="${FILTER:-FullyQualifiedName~K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning}"
MOD="${MOD:-Silent Sion Restoration}"

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.
Runs a K2 Full round-trip LongRunning test: merge neocities fixture with golden TOML,
overlay ingested NLP instructions, download the archive, extract, and install 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)
--mod NAME Mod name for round-trip overlay (default: Silent Sion Restoration)
-h, --help Show this help

Environment:
GOLDEN, FILTER (dotnet test --filter override)
GOLDEN, FILTER, MOD (MOD selects default FILTER when FILTER unset)

Examples:
./scripts/agents/k2_merged_roundtrip_download_install_smoke.sh
MOD="Prestige Class Saving Throw Fixes" \\
FILTER='FullyQualifiedName~K2FullGuideFixture_RoundTripPrestige_DownloadAndInstalls_LongRunning' \\
./scripts/agents/k2_merged_roundtrip_download_install_smoke.sh
EOF
}

while [[ $# -gt 0 ]]; do
case "$1" in
--golden) GOLDEN="$2"; shift 2 ;;
--mod) MOD="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done

if [[ "$MOD" == "Prestige Class Saving Throw Fixes" && "$FILTER" == "FullyQualifiedName~K2FullGuideFixture_RoundTripSilentSion_DownloadAndInstalls_LongRunning" ]]; then
FILTER="FullyQualifiedName~K2FullGuideFixture_RoundTripPrestige_DownloadAndInstalls_LongRunning"
fi

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
Expand Down
128 changes: 128 additions & 0 deletions src/ModSync.Core/Parsing/DraftInstructionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,134 @@ internal static List<string> ExpandLooseFileMoveSources(
return merged;
}

/// <summary>
/// Builds sandboxed Move sources for a named folder (optionally path-like prose), including nested
/// paths after Extract and slug/fuzzy variants when archive folders differ from guide wording.
/// </summary>
[NotNull]
internal static List<string> BuildFolderMoveSources([NotNull] string folderPhrase, int maxNestedDepth = 3)
{
if (string.IsNullOrWhiteSpace(folderPhrase))
{
throw new ArgumentException("Folder phrase is required.", nameof(folderPhrase));
}

string trimmed = folderPhrase.Trim().Trim('"', '\'', '.', ' ', ',', ';');
trimmed = Regex.Replace(trimmed, @"^\s*the\s+", string.Empty, RegexOptions.IgnoreCase);

var folderCandidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
trimmed.Replace('/', '\\'),
};

foreach (string slug in GenerateFolderSlugCandidates(trimmed))
{
folderCandidates.Add(slug);
}

var sources = new List<string>();
foreach (string candidate in folderCandidates)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}

sources.Add($"{ModDirectoryPlaceholder}\\{candidate}\\*");

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

string fuzzySegment = BuildFuzzyFolderSegmentPattern(candidate);
if (!string.IsNullOrWhiteSpace(fuzzySegment))
{
for (int depth = 1; depth <= maxNestedDepth; depth++)
{
sources.Add($"{ModDirectoryPlaceholder}\\{string.Join("\\", Enumerable.Repeat("*", depth))}\\{fuzzySegment}\\*");
}
}
}

return sources;
}

/// <summary>
/// Expands existing Move sources with nested folder search paths for guide prose folder names.
/// </summary>
[NotNull]
internal static List<string> ExpandFolderMoveSources(
[CanBeNull] IEnumerable<string> existingSources,
[NotNull] string folderPhrase,
int maxNestedDepth = 3)
{
var merged = new List<string>();
if (existingSources != null)
{
merged.AddRange(existingSources.Where(source => !string.IsNullOrWhiteSpace(source)));
}

foreach (string source in BuildFolderMoveSources(folderPhrase, maxNestedDepth))
{
if (!merged.Any(existing => string.Equals(existing, source, StringComparison.OrdinalIgnoreCase)))
{
merged.Add(source);
}
}

return merged;
}

[NotNull]
private static IEnumerable<string> GenerateFolderSlugCandidates([NotNull] string phrase)
{
string[] segments = phrase
.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries)
.Select(segment => segment.Trim())
.Where(segment => segment.Length > 0)
.ToArray();

if (segments.Length == 0)
{
yield break;
}

yield return string.Join("\\", segments);

if (segments.Length >= 2)
{
string head = segments[0].Replace(" ", string.Empty).ToLowerInvariant();
string tail = string.Join(" ", segments.Skip(1)).ToLowerInvariant();
tail = Regex.Replace(tail, @"\bsith\s+lord\b", "sithlord", RegexOptions.IgnoreCase);
yield return $"{head}_{tail}";
}
}

[CanBeNull]
private static string BuildFuzzyFolderSegmentPattern([NotNull] string folderCandidate)
{
string segment = folderCandidate;
int lastSeparator = Math.Max(folderCandidate.LastIndexOf('\\'), folderCandidate.LastIndexOf('/'));
if (lastSeparator >= 0 && lastSeparator < folderCandidate.Length - 1)
{
segment = folderCandidate.Substring(lastSeparator + 1);
}

string merged = Regex.Replace(segment.ToLowerInvariant(), @"\bsith\s+lord\b", "sithlord");
string[] tokens = merged
.Split(new[] { ' ', '_', '-', '/', '\\' }, StringSplitOptions.RemoveEmptyEntries)
.Where(token => token.Length > 3 || string.Equals(token, "fixes", StringComparison.OrdinalIgnoreCase))
.ToArray();

if (tokens.Length == 0)
{
return null;
}

return "*" + string.Join("*", tokens) + "*";
}

/// <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: 3 additions & 5 deletions src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1048,15 +1048,13 @@ private static List<string> ExtractSources([NotNull] string sourceText, [NotNull
if (folderMatch.Success)
{
string folder = folderMatch.Groups["folder"].Value.Trim();
// Normalize folder name
folder = folder.Trim('"', '\'', ' ');
sources.Add($"<<modDirectory>>\\{folder}\\*");
sources.AddRange(DraftInstructionService.BuildFolderMoveSources(folder));

// Check for second folder
if (folderMatch.Groups["folder2"].Success)
{
string folder2 = folderMatch.Groups["folder2"].Value.Trim().Trim('"', '\'', ' ');
sources.Add($"<<modDirectory>>\\{folder2}\\*");
sources.AddRange(DraftInstructionService.BuildFolderMoveSources(folder2));
}
return sources;
}
Expand Down Expand Up @@ -1114,7 +1112,7 @@ private static List<string> ExtractSources([NotNull] string sourceText, [NotNull
// Otherwise treat as folder
else if (actionType == Instruction.ActionType.Move || actionType == Instruction.ActionType.Copy)
{
sources.Add($"<<modDirectory>>\\{cleaned}\\*");
sources.AddRange(DraftInstructionService.BuildFolderMoveSources(cleaned));
}
else
{
Expand Down
87 changes: 87 additions & 0 deletions src/ModSync.Tests/GuideIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,93 @@ public void DraftInstructions_SilentSionMoveJust_IncludesNestedSearchPaths()
});
}

[Test]
public void DraftInstructions_PrestigeClassAdvisory_IncludesNestedFolderSearch()
{
ModComponent component = new ModComponent
{
Name = "Prestige Class Saving Throw Fixes",
InstallationMethod = "Loose-File Mod",
Directions = "I advise users to only install the Jedi Master/Sith Lord fixes.",
};

IReadOnlyList<DraftInstructionResult> results = DraftInstructionService.GenerateDraftInstructions(new[] { component });

Assert.Multiple(() =>
{
Assert.That(results, Has.Count.EqualTo(1));
Assert.That(component.Instructions[0].Action, Is.EqualTo(Instruction.ActionType.Move));
Assert.That(
component.Instructions[0].Source.Any(source =>
source.IndexOf("Jedi Master", StringComparison.OrdinalIgnoreCase) >= 0
&& source.IndexOf('*', StringComparison.Ordinal) >= 0),
Is.True,
"Folder Move should include nested search paths for Jedi Master/Sith Lord fixes");
Assert.That(
component.Instructions[0].Source.Any(source =>
source.IndexOf("sithlord", StringComparison.OrdinalIgnoreCase) >= 0
&& source.IndexOf("fixes", StringComparison.OrdinalIgnoreCase) >= 0),
Is.True,
"Folder Move should include slug/fuzzy variants for jedimaster_sithlord fixes");
});
}

[Test]
public void K2FullGuideFixture_IngestedPrestige_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 nestedFolder = Path.Combine(
modDir,
"TSL_prestige_save_fixes",
"TSL_prestige_save_fixes",
"jedimaster_sithlord fixes");
Directory.CreateDirectory(nestedFolder);
File.WriteAllText(Path.Combine(nestedFolder, "prestige_fix.2da"), "2DA V2.0\n");

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, "Prestige Class Saving Throw Fixes");

int installExit = ModBuildConverter.Run(new[]
{
"install",
"--input", ingestedToml,
"--game-dir", kotorDir,
"--source-dir", modDir,
"--select", "mod:Prestige Class Saving Throw Fixes",
"--skip-validation",
"--best-effort",
"-y",
});

Assert.Multiple(() =>
{
Assert.That(installExit, Is.EqualTo(0));
Assert.That(File.Exists(Path.Combine(kotorDir, "Override", "prestige_fix.2da")), Is.True,
"Folder Move draft should find prestige_fix.2da after extract-style nesting");
});
}

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