1030 lines
44 KiB
C#
1030 lines
44 KiB
C#
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<T>
|
|
{
|
|
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<T> Success(T value) => new Result<T>(value, null);
|
|
public static Result<T> Failure(string error) => new Result<T>(default!, error);
|
|
}
|
|
|
|
public static class NpcConverter
|
|
{
|
|
private static Dictionary<string, object> ParseToml(string content)
|
|
{
|
|
var root = new Dictionary<string, object>();
|
|
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<string, object>))
|
|
{
|
|
var newTable = new Dictionary<string, object>();
|
|
currentTable[part] = newTable;
|
|
currentTable = newTable;
|
|
}
|
|
else
|
|
{
|
|
currentTable = (Dictionary<string, object>)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<string, object>();
|
|
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<Dictionary<string, object>> 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<Dictionary<string, object>>.Success(document);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result<Dictionary<string, object>>.Failure($"failed to parse {path}: {ex.Message}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result<Dictionary<string, object>>.Failure($"failed to read {path}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static object? Navigate(Dictionary<string, object> doc, params string[] path)
|
|
{
|
|
object current = doc;
|
|
foreach (var key in path)
|
|
{
|
|
if (current is Dictionary<string, object> dict)
|
|
{
|
|
if (!dict.TryGetValue(key, out var next))
|
|
return null;
|
|
current = next;
|
|
}
|
|
else
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
public static Result<int> AbilityScoreModifier(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<int>.Failure($"unsupported ability modifier tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "ability_modifiers", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"ability modifier value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
if (value is string text && text == "n/a")
|
|
{
|
|
return Result<int>.Failure($"ability modifier tier `{normalizedTier}` is unavailable for level `{level}`");
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid ability modifier value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> PerceptionModifier(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low" && normalizedTier != "terrible")
|
|
{
|
|
return Result<int>.Failure($"unsupported perception tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, `low`, or `terrible`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "perception", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"perception value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid perception value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> SkillModifier(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<int>.Failure($"unsupported skill tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "skills", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"skill value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
if (value is Dictionary<string, object> range && normalizedTier == "low")
|
|
{
|
|
if (!range.TryGetValue("minimum", out var minObj) || minObj is not long minLong)
|
|
{
|
|
return Result<int>.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<int>.Failure($"missing skill maximum for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
if (minLong < int.MinValue || minLong > int.MaxValue)
|
|
{
|
|
return Result<int>.Failure($"skill minimum `{minLong}` is out of range for i32");
|
|
}
|
|
if (maxLong < int.MinValue || maxLong > int.MaxValue)
|
|
{
|
|
return Result<int>.Failure($"skill maximum `{maxLong}` is out of range for i32");
|
|
}
|
|
|
|
int minimum = (int)minLong;
|
|
int maximum = (int)maxLong;
|
|
|
|
if (minimum > maximum)
|
|
{
|
|
return Result<int>.Failure($"invalid skill range for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
return Result<int>.Success(Random.Shared.Next(minimum, maximum + 1));
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid skill value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> Ac(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<int>.Failure($"unsupported armor class tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "armor_class", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"armor class value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid armor class value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> SavingThrow(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low" && normalizedTier != "terrible")
|
|
{
|
|
return Result<int>.Failure($"unsupported saving throw tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, `low`, or `terrible`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "saving_throws", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"saving throw value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid saving throw value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> Hp(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<int>.Failure($"unsupported hit point tier `{normalizedTier}`; expected `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "hit_points", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.Failure($"no hit point entry found for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
if (value is Dictionary<string, object> range)
|
|
{
|
|
if (!range.TryGetValue("minimum", out var minObj) || minObj is not long minLong)
|
|
{
|
|
return Result<int>.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<int>.Failure($"missing hit point maximum for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
if (minLong < int.MinValue || minLong > int.MaxValue)
|
|
{
|
|
return Result<int>.Failure($"hit point minimum `{minLong}` is out of range for i32");
|
|
}
|
|
if (maxLong < int.MinValue || maxLong > int.MaxValue)
|
|
{
|
|
return Result<int>.Failure($"hit point maximum `{maxLong}` is out of range for i32");
|
|
}
|
|
|
|
int minimum = (int)minLong;
|
|
int maximum = (int)maxLong;
|
|
|
|
if (minimum > maximum)
|
|
{
|
|
return Result<int>.Failure($"invalid hit point range for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
return Result<int>.Success(Random.Shared.Next(minimum, maximum + 1));
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid hit point value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> ResistanceOrWeakness(int level, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var levelTable = Navigate(docResult.Value!, "resistances_and_weaknesses", "levels", level.ToString());
|
|
if (levelTable == null || levelTable is not Dictionary<string, object> range)
|
|
{
|
|
return Result<int>.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<int>.Failure($"missing resistance/weakness minimum for system `{normalizedSystem}`, level `{level}`");
|
|
}
|
|
if (!range.TryGetValue("maximum", out var maxObj) || maxObj is not long maxLong)
|
|
{
|
|
return Result<int>.Failure($"missing resistance/weakness maximum for system `{normalizedSystem}`, level `{level}`");
|
|
}
|
|
|
|
if (minLong < int.MinValue || minLong > int.MaxValue)
|
|
{
|
|
return Result<int>.Failure($"resistance/weakness minimum `{minLong}` is out of range for i32");
|
|
}
|
|
if (maxLong < int.MinValue || maxLong > int.MaxValue)
|
|
{
|
|
return Result<int>.Failure($"resistance/weakness maximum `{maxLong}` is out of range for i32");
|
|
}
|
|
|
|
int minimum = (int)minLong;
|
|
int maximum = (int)maxLong;
|
|
|
|
if (minimum > maximum)
|
|
{
|
|
return Result<int>.Failure($"invalid resistance/weakness range for system `{normalizedSystem}`, level `{level}`");
|
|
}
|
|
|
|
return Result<int>.Success(Random.Shared.Next(minimum, maximum + 1));
|
|
}
|
|
|
|
public static Result<int> StrikeAttackBonus(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<int>.Failure($"unsupported strike attack bonus tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "strike_attack_bonus", "levels", level.ToString(), normalizedTier);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"strike attack bonus value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid strike attack bonus value for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<string> StrikeDamageRoll(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<string>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<string>.Failure($"unsupported strike damage tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<string>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "strike_damage", "levels", level.ToString(), normalizedTier);
|
|
if (value == null || value is not Dictionary<string, object> damageTable)
|
|
{
|
|
return Result<string>.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<string>.Success(dice);
|
|
}
|
|
|
|
return Result<string>.Failure($"invalid strike damage roll for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<int> StrikeDamageAverage(int level, string modifierTier, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string normalizedTier = modifierTier.Trim().ToLowerInvariant();
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
if (normalizedTier != "extreme" && normalizedTier != "high" && normalizedTier != "moderate" && normalizedTier != "low")
|
|
{
|
|
return Result<int>.Failure($"unsupported strike damage tier `{normalizedTier}`; expected `extreme`, `high`, `moderate`, or `low`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "strike_damage", "levels", level.ToString(), normalizedTier);
|
|
if (value == null || value is not Dictionary<string, object> damageTable)
|
|
{
|
|
return Result<int>.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<int>.Failure($"strike damage average `{average}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)average);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid strike damage average for system `{normalizedSystem}`, level `{level}`, tier `{normalizedTier}`");
|
|
}
|
|
|
|
public static Result<string> AreaDamageRoll(int level, bool unlimited, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string column = unlimited ? "unlimited" : "limited";
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<string>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<string>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "area_damage", "levels", level.ToString(), column);
|
|
if (value == null || value is not Dictionary<string, object> damageTable)
|
|
{
|
|
return Result<string>.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<string>.Success(dice);
|
|
}
|
|
|
|
return Result<string>.Failure($"invalid area damage roll for system `{normalizedSystem}`, level `{level}`, column `{column}`");
|
|
}
|
|
|
|
public static Result<int> AreaDamageAverage(int level, bool unlimited, string system)
|
|
{
|
|
string normalizedSystem = system.Trim().ToLowerInvariant();
|
|
string column = unlimited ? "unlimited" : "limited";
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "area_damage", "levels", level.ToString(), column);
|
|
if (value == null || value is not Dictionary<string, object> damageTable)
|
|
{
|
|
return Result<int>.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<int>.Failure($"area damage average `{average}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)average);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid area damage average for system `{normalizedSystem}`, level `{level}`, column `{column}`");
|
|
}
|
|
|
|
public static Result<int> 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<int>.Failure($"unsupported spell dc tier `{normalizedTier}`; expected `extreme`, `high`, or `moderate`");
|
|
}
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "spellcasting", "levels", level.ToString(), column);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"spell dc value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid spell dc value for system `{normalizedSystem}`, level `{level}`, column `{column}`");
|
|
}
|
|
|
|
public static Result<int> 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<int>.Failure($"unsupported spell attack bonus tier `{normalizedTier}`; expected `extreme`, `high`, or `moderate`");
|
|
}
|
|
|
|
if (normalizedSystem != "pf2e" && normalizedSystem != "sf2e")
|
|
{
|
|
return Result<int>.Failure($"unsupported game system `{normalizedSystem}`; expected `pf2e` or `sf2e`");
|
|
}
|
|
|
|
var docResult = LoadTableDocument(normalizedSystem);
|
|
if (!docResult.IsSuccess) return Result<int>.Failure(docResult.Error!);
|
|
|
|
var value = Navigate(docResult.Value!, "spellcasting", "levels", level.ToString(), column);
|
|
if (value == null)
|
|
{
|
|
return Result<int>.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<int>.Failure($"spell attack bonus value `{number}` is out of range for i32");
|
|
}
|
|
return Result<int>.Success((int)number);
|
|
}
|
|
|
|
return Result<int>.Failure($"invalid spell attack bonus value for system `{normalizedSystem}`, level `{level}`, column `{column}`");
|
|
}
|
|
}
|
|
|
|
public class NpcGenerator
|
|
{
|
|
private readonly ILlmService _llmService;
|
|
private Dictionary<string, (string question, string constraints)> _prompts = new();
|
|
private string _retryPrefix = "";
|
|
|
|
public NpcGenerator(ILlmService llmService)
|
|
{
|
|
_llmService = llmService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load all prompts from llmflow/npc.toml
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ask LLM a question with up to 3 retry attempts and constraint validation
|
|
/// </summary>
|
|
public async Task<(bool success, string response)> AskLlmAsync(
|
|
string baseUrl,
|
|
string model,
|
|
string promptKey,
|
|
Dictionary<string, string> replacements,
|
|
List<string> 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, promptKey);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate name if not provided. Tries name_from_description_and_level, then name_from_level, then name
|
|
/// </summary>
|
|
public async Task<string> GenerateNameAsync(
|
|
string baseUrl,
|
|
string model,
|
|
string description,
|
|
int level,
|
|
List<string> imagePaths)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(description))
|
|
{
|
|
// Try generating from description + level
|
|
var replacements = new Dictionary<string, string>
|
|
{
|
|
{ "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<string, string>();
|
|
(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<string, string>
|
|
{
|
|
{ "level", level.ToString() }
|
|
};
|
|
|
|
var (levelSuccess, levelName) = await AskLlmAsync(baseUrl, model, "name_from_level", levelReplacements, imagePaths);
|
|
return (levelSuccess && !string.IsNullOrWhiteSpace(levelName)) ? levelName : "NPC";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate description if not provided. Uses description_from_name_and_level
|
|
/// </summary>
|
|
public async Task<string> GenerateDescriptionAsync(
|
|
string baseUrl,
|
|
string model,
|
|
string name,
|
|
int level,
|
|
List<string> imagePaths)
|
|
{
|
|
var replacements = new Dictionary<string, string>
|
|
{
|
|
{ "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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate a response against constraints and return normalized response
|
|
/// </summary>
|
|
private (bool isValid, string normalizedResponse) ValidateResponse(string response, string constraints, string promptKey = "")
|
|
{
|
|
// Strip punctuation characters for validation (except for name and description prompts)
|
|
var isNameOrDescriptionPrompt = promptKey.Contains("name") || promptKey.Contains("description");
|
|
var cleanedResponse = response;
|
|
|
|
if (!isNameOrDescriptionPrompt)
|
|
{
|
|
// Strip: _ , ; - ' . ! ? "
|
|
var charsToStrip = "_,;-'.!?\"";
|
|
cleanedResponse = new string(response.Where(c => !charsToStrip.Contains(c)).ToArray()).Trim();
|
|
}
|
|
|
|
var allowed = constraints.Trim();
|
|
var optionsStart = allowed.IndexOf(":");
|
|
|
|
List<string> 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, cleanedResponse, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return (true, cleanedResponse);
|
|
}
|
|
|
|
// Try first word match
|
|
var firstWord = cleanedResponse.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(cleanedResponse), cleanedResponse);
|
|
}
|
|
}
|
|
}
|