using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using finder2e_foundry_converter.Services; namespace finder2e_foundry_converter.Converters { public struct Result { public T Value { get; } public string? Error { get; } public bool IsSuccess => Error == null; private Result(T value, string? error) { Value = value; Error = error; } public static Result Success(T value) => new Result(value, null); public static Result Failure(string error) => new Result(default!, error); } public static class NpcConverter { private static Dictionary ParseToml(string content) { var root = new Dictionary(); var currentTable = root; var lines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); foreach (var rawLine in lines) { var line = rawLine.Trim(); if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; if (line.StartsWith("[") && line.EndsWith("]")) { var section = line.Substring(1, line.Length - 2).Trim(); var parts = section.Split('.'); currentTable = root; foreach (var p in parts) { var part = p.Trim().Trim('"'); if (!currentTable.TryGetValue(part, out var next) || !(next is Dictionary)) { var newTable = new Dictionary(); currentTable[part] = newTable; currentTable = newTable; } else { currentTable = (Dictionary)next; } } } else if (line.Contains("=")) { var idx = line.IndexOf('='); var key = line.Substring(0, idx).Trim().Trim('"'); var valStr = line.Substring(idx + 1).Trim(); if (string.IsNullOrEmpty(key)) continue; currentTable[key] = ParseTomlValue(valStr); } } return root; } private static object ParseTomlValue(string valStr) { if (valStr.StartsWith("{") && valStr.EndsWith("}")) { var inlineTable = new Dictionary(); var content = valStr.Substring(1, valStr.Length - 2).Trim(); if (!string.IsNullOrEmpty(content)) { var pairs = content.Split(','); foreach (var pair in pairs) { var idx = pair.IndexOf('='); if (idx >= 0) { var k = pair.Substring(0, idx).Trim().Trim('"'); var vStr = pair.Substring(idx + 1).Trim(); inlineTable[k] = ParseTomlValue(vStr); } } } return inlineTable; } if (valStr.StartsWith("\"") && valStr.EndsWith("\"")) { return valStr.Substring(1, valStr.Length - 2); } if (long.TryParse(valStr, out var num)) { return num; } return valStr; } private static Result> LoadTableDocument(string normalizedSystem) { string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config", $"{normalizedSystem}_tables.toml"); try { if (!File.Exists(path)) { // Fallback attempt: sometimes config might be in working directory during dev var alternativePath = Path.Combine(Directory.GetCurrentDirectory(), "config", $"{normalizedSystem}_tables.toml"); if (File.Exists(alternativePath)) { path = alternativePath; } } string rawContents = File.ReadAllText(path); try { var document = ParseToml(rawContents); return Result>.Success(document); } catch (Exception ex) { return Result>.Failure($"failed to parse {path}: {ex.Message}"); } } catch (Exception ex) { return Result>.Failure($"failed to read {path}: {ex.Message}"); } } private static object? Navigate(Dictionary doc, params string[] path) { object current = doc; foreach (var key in path) { if (current is Dictionary dict) { if (!dict.TryGetValue(key, out var next)) return null; current = next; } else { return null; } } return current; } public static Result AbilityScoreModifier(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported ability modifier tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "ability_modifiers", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no ability modifier entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"ability modifier value `{number}` is out of range for i32"); } return Result.Success((int)number); } if (value is string text && text == "n/a") { return Result.Failure($"ability modifier tier `{normalizedTier}` is unavailable for level `{level}`"); } return Result.Failure($"invalid ability modifier value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result PerceptionModifier(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low" && normalizedTier != "terrible") { return Result.Failure($"unsupported perception tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, `low`, or `terrible`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "perception", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no perception entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"perception value `{number}` is out of range for i32"); } return Result.Success((int)number); } return Result.Failure($"invalid perception value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result SkillModifier(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported skill tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "skills", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no skill entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"skill value `{number}` is out of range for i32"); } return Result.Success((int)number); } if (value is Dictionary range && normalizedTier == "low") { if (!range.TryGetValue("minimum", out var minObj) || minObj is not long minLong) { return Result.Failure($"missing skill minimum for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (!range.TryGetValue("maximum", out var maxObj) || maxObj is not long maxLong) { return Result.Failure($"missing skill maximum for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (minLong < int.MinValue || minLong > int.MaxValue) { return Result.Failure($"skill minimum `{minLong}` is out of range for i32"); } if (maxLong < int.MinValue || maxLong > int.MaxValue) { return Result.Failure($"skill maximum `{maxLong}` is out of range for i32"); } int minimum = (int)minLong; int maximum = (int)maxLong; if (minimum > maximum) { return Result.Failure($"invalid skill range for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } return Result.Success(Random.Shared.Next(minimum, maximum + 1)); } return Result.Failure($"invalid skill value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result Ac(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported armor class tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "armor_class", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no armor class entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"armor class value `{number}` is out of range for i32"); } return Result.Success((int)number); } return Result.Failure($"invalid armor class value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result SavingThrow(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low" && normalizedTier != "terrible") { return Result.Failure($"unsupported saving throw tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, `low`, or `terrible`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "saving_throws", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no saving throw entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"saving throw value `{number}` is out of range for i32"); } return Result.Success((int)number); } return Result.Failure($"invalid saving throw value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result Hp(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported hit point tier `{normalizedTier}`; expected `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "hit_points", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no hit point entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is Dictionary range) { if (!range.TryGetValue("minimum", out var minObj) || minObj is not long minLong) { return Result.Failure($"missing hit point minimum for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (!range.TryGetValue("maximum", out var maxObj) || maxObj is not long maxLong) { return Result.Failure($"missing hit point maximum for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (minLong < int.MinValue || minLong > int.MaxValue) { return Result.Failure($"hit point minimum `{minLong}` is out of range for i32"); } if (maxLong < int.MinValue || maxLong > int.MaxValue) { return Result.Failure($"hit point maximum `{maxLong}` is out of range for i32"); } int minimum = (int)minLong; int maximum = (int)maxLong; if (minimum > maximum) { return Result.Failure($"invalid hit point range for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } return Result.Success(Random.Shared.Next(minimum, maximum + 1)); } return Result.Failure($"invalid hit point value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result ResistanceOrWeakness(int level, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var levelTable = Navigate(docResult.Value!, "resistances_and_weaknesses", "levels", level.ToString()); if (levelTable == null || levelTable is not Dictionary range) { return Result.Failure($"no resistance/weakness entry found for system `{normalizedSystem}`, level `{level}`"); } if (!range.TryGetValue("minimum", out var minObj) || minObj is not long minLong) { return Result.Failure($"missing resistance/weakness minimum for system `{normalizedSystem}`, level `{level}`"); } if (!range.TryGetValue("maximum", out var maxObj) || maxObj is not long maxLong) { return Result.Failure($"missing resistance/weakness maximum for system `{normalizedSystem}`, level `{level}`"); } if (minLong < int.MinValue || minLong > int.MaxValue) { return Result.Failure($"resistance/weakness minimum `{minLong}` is out of range for i32"); } if (maxLong < int.MinValue || maxLong > int.MaxValue) { return Result.Failure($"resistance/weakness maximum `{maxLong}` is out of range for i32"); } int minimum = (int)minLong; int maximum = (int)maxLong; if (minimum > maximum) { return Result.Failure($"invalid resistance/weakness range for system `{normalizedSystem}`, level `{level}`"); } return Result.Success(Random.Shared.Next(minimum, maximum + 1)); } public static Result StrikeAttackBonus(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported strike attack bonus tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "strike_attack_bonus", "levels", level.ToString(), normalizedTier); if (value == null) { return Result.Failure($"no strike attack bonus entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"strike attack bonus value `{number}` is out of range for i32"); } return Result.Success((int)number); } return Result.Failure($"invalid strike attack bonus value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result StrikeDamageRoll(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported strike damage tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "strike_damage", "levels", level.ToString(), normalizedTier); if (value == null || value is not Dictionary damageTable) { return Result.Failure($"no strike damage entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (damageTable.TryGetValue("dice", out var diceObj) && diceObj is string dice) { return Result.Success(dice); } return Result.Failure($"invalid strike damage roll for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result StrikeDamageAverage(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low") { return Result.Failure($"unsupported strike damage tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "strike_damage", "levels", level.ToString(), normalizedTier); if (value == null || value is not Dictionary damageTable) { return Result.Failure($"no strike damage entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } if (damageTable.TryGetValue("average", out var avgObj) && avgObj is long average) { if (average < int.MinValue || average > int.MaxValue) { return Result.Failure($"strike damage average `{average}` is out of range for i32"); } return Result.Success((int)average); } return Result.Failure($"invalid strike damage average for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`"); } public static Result AreaDamageRoll(int level, bool unlimited, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string column = unlimited ? "unlimited" : "limited"; if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "area_damage", "levels", level.ToString(), column); if (value == null || value is not Dictionary damageTable) { return Result.Failure($"no area damage entry found for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } if (damageTable.TryGetValue("dice", out var diceObj) && diceObj is string dice) { return Result.Success(dice); } return Result.Failure($"invalid area damage roll for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } public static Result AreaDamageAverage(int level, bool unlimited, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string column = unlimited ? "unlimited" : "limited"; if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "area_damage", "levels", level.ToString(), column); if (value == null || value is not Dictionary damageTable) { return Result.Failure($"no area damage entry found for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } if (damageTable.TryGetValue("average", out var avgObj) && avgObj is long average) { if (average < int.MinValue || average > int.MaxValue) { return Result.Failure($"area damage average `{average}` is out of range for i32"); } return Result.Success((int)average); } return Result.Failure($"invalid area damage average for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } public static Result SpellDc(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); string column = normalizedTier switch { "extreme" => "extreme_dc", "high" => "high_dc", "moderate" => "moderate_dc", _ => null! }; if (column == null) { return Result.Failure($"unsupported spell dc tier `{normalizedTier}`; expected `extreme`, `high`, or `moderate`"); } if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "spellcasting", "levels", level.ToString(), column); if (value == null) { return Result.Failure($"no spell dc entry found for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"spell dc value `{number}` is out of range for i32"); } return Result.Success((int)number); } return Result.Failure($"invalid spell dc value for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } public static Result SpellAttackBonus(int level, string modifierTier, string system) { string normalizedSystem = system.Trim().ToLowerInvariant(); string normalizedTier = modifierTier.Trim().ToLowerInvariant(); string column = normalizedTier switch { "extreme" => "extreme_spell_attack", "high" => "high_spell_attack", "moderate" => "moderate_spell_attack", _ => null! }; if (column == null) { return Result.Failure($"unsupported spell attack bonus tier `{normalizedTier}`; expected `extreme`, `high`, or `moderate`"); } if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e") { return Result.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`"); } var docResult = LoadTableDocument(normalizedSystem); if (!docResult.IsSuccess) return Result.Failure(docResult.Error!); var value = Navigate(docResult.Value!, "spellcasting", "levels", level.ToString(), column); if (value == null) { return Result.Failure($"no spell attack bonus entry found for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } if (value is long number) { if (number < int.MinValue || number > int.MaxValue) { return Result.Failure($"spell attack bonus value `{number}` is out of range for i32"); } return Result.Success((int)number); } return Result.Failure($"invalid spell attack bonus value for system `{normalizedSystem}`, level `{level}`, column `{column}`"); } } public class NpcGenerator { private readonly ILlmService _llmService; private Dictionary _prompts = new(); private string _retryPrefix = ""; public NpcGenerator(ILlmService llmService) { _llmService = llmService; } /// /// Load all prompts from llmflow/npc.toml /// public async Task LoadPromptsAsync() { _prompts.Clear(); _retryPrefix = ""; var cwd = Directory.GetCurrentDirectory(); var tomlPath = Path.Combine(cwd, "llmflow", "npc.toml"); if (!File.Exists(tomlPath)) { Console.WriteLine($"Warning: TOML file not found at {tomlPath}"); return; } var lines = await File.ReadAllLinesAsync(tomlPath); string? currentSection = null; foreach (var rawLine in lines) { var line = rawLine.Trim(); if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; // Parse section headers [section_name] if (line.StartsWith("[") && line.EndsWith("]")) { currentSection = line.Substring(1, line.Length - 2).Trim(); continue; } // Parse key = "value" if (line.Contains("=")) { var idx = line.IndexOf('='); var key = line.Substring(0, idx).Trim(); var valStr = line.Substring(idx + 1).Trim().Trim('"'); if (key == "retry_prefix") { _retryPrefix = valStr; Console.WriteLine($"Loaded retry_prefix"); } else if (currentSection != null) { if (key == "question") { if (!_prompts.ContainsKey(currentSection)) _prompts[currentSection] = (valStr, string.Empty); else _prompts[currentSection] = (valStr, _prompts[currentSection].constraints); } else if (key == "constraints") { if (!_prompts.ContainsKey(currentSection)) _prompts[currentSection] = (string.Empty, valStr); else _prompts[currentSection] = (_prompts[currentSection].question, valStr); } } } } Console.WriteLine($"Loaded {_prompts.Count} prompt sections from npc.toml"); } /// /// Ask LLM a question with up to 3 retry attempts and constraint validation /// public async Task<(bool success, string response)> AskLlmAsync( string baseUrl, string model, string promptKey, Dictionary replacements, List imagePaths) { if (!_prompts.TryGetValue(promptKey, out var prompt_tuple)) { Console.WriteLine($"Warning: Prompt '{promptKey}' not found in loaded prompts"); return (false, string.Empty); } var (questionTemplate, constraints) = prompt_tuple; var prompt = questionTemplate; foreach (var kvp in replacements) { prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); } string lastResponse = string.Empty; for (int attempt = 1; attempt <= 3; attempt++) { Console.WriteLine($"\n=== Asking LLM for '{promptKey}' (attempt {attempt}/3) ==="); Console.WriteLine($"Prompt: {prompt}"); if (!string.IsNullOrWhiteSpace(constraints)) Console.WriteLine($"Constraints: {constraints}"); var resp = await _llmService.AskAsync(baseUrl, model, prompt, imagePaths); var trimmed = resp?.Trim() ?? string.Empty; Console.WriteLine($"LLM Response: '{trimmed}'"); // Check for error response if (trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"-> Response is an error, will retry"); prompt = _retryPrefix + "\nPrevious error: " + trimmed + "\n\n" + questionTemplate; foreach (var kvp in replacements) { prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); } lastResponse = trimmed; continue; } // Validate against constraints if available if (!string.IsNullOrWhiteSpace(constraints)) { var (isValid, normalizedResponse) = ValidateResponse(trimmed, constraints); if (isValid) { Console.WriteLine($"✓ Response is valid: '{normalizedResponse}'"); return (true, normalizedResponse); } else { Console.WriteLine($"✗ Response '{trimmed}' does not match constraints"); if (attempt < 3) { prompt = _retryPrefix + $"\nPrevious response was: '{trimmed}'\n\nPlease respond with the correct format:\n{constraints}\n\n{questionTemplate}"; foreach (var kvp in replacements) { prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); } } lastResponse = trimmed; continue; } } else { // No constraints, accept any non-empty response if (!string.IsNullOrWhiteSpace(trimmed)) { Console.WriteLine($"✓ Response accepted (no constraints)"); return (true, trimmed); } else { Console.WriteLine($"✗ Empty response, will retry"); if (attempt < 3) { prompt = _retryPrefix + "\nPrevious response was empty.\n\n" + questionTemplate; foreach (var kvp in replacements) { prompt = prompt.Replace($"{{{kvp.Key}}}", kvp.Value); } } lastResponse = trimmed; } } } Console.WriteLine($"\n✗✗✗ Failed to get valid response for '{promptKey}' after 3 attempts. Last response: '{lastResponse}'"); return (false, lastResponse); } /// /// Generate name if not provided. Tries name_from_description_and_level, then name_from_level, then name /// public async Task GenerateNameAsync( string baseUrl, string model, string description, int level, List imagePaths) { if (!string.IsNullOrWhiteSpace(description)) { // Try generating from description + level var replacements = new Dictionary { { "description", description }, { "level", level.ToString() } }; var (success, name) = await AskLlmAsync(baseUrl, model, "name_from_description_and_level", replacements, imagePaths); if (success && !string.IsNullOrWhiteSpace(name)) return name; // Fallback to just description replacements = new Dictionary(); (success, name) = await AskLlmAsync(baseUrl, model, "name", replacements, imagePaths); if (success && !string.IsNullOrWhiteSpace(name)) return name; } // If no description, generate from level alone var levelReplacements = new Dictionary { { "level", level.ToString() } }; var (levelSuccess, levelName) = await AskLlmAsync(baseUrl, model, "name_from_level", levelReplacements, imagePaths); return (levelSuccess && !string.IsNullOrWhiteSpace(levelName)) ? levelName : "NPC"; } /// /// Generate description if not provided. Uses description_from_name_and_level /// public async Task GenerateDescriptionAsync( string baseUrl, string model, string name, int level, List imagePaths) { var replacements = new Dictionary { { "name", name }, { "level", level.ToString() } }; var (success, description) = await AskLlmAsync(baseUrl, model, "description_from_name_and_level", replacements, imagePaths); return (success && !string.IsNullOrWhiteSpace(description)) ? description : string.Empty; } /// /// Validate a response against constraints and return normalized response /// private (bool isValid, string normalizedResponse) ValidateResponse(string response, string constraints) { var allowed = constraints.Trim(); var optionsStart = allowed.IndexOf(":"); List options = new(); if (optionsStart >= 0) { var after = allowed.Substring(optionsStart + 1); // Split by commas and newlines var parts = after.Split(new[] { ',', '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (var p in parts) { var t = p.Replace("or", "", StringComparison.OrdinalIgnoreCase).Trim(); if (!string.IsNullOrEmpty(t)) options.Add(t.Trim().Trim('.')); } } Console.WriteLine($"Parsed allowed options: {string.Join(", ", options)}"); if (options.Count > 0) { // Check exact match if (options.Any(o => string.Equals(o, response, StringComparison.OrdinalIgnoreCase))) { return (true, response); } // Try first word match var firstWord = response.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; if (options.Any(o => string.Equals(o, firstWord, StringComparison.OrdinalIgnoreCase))) { return (true, firstWord); } return (false, response); } // No options parsed, accept any non-empty response return (!string.IsNullOrWhiteSpace(response), response); } } }