| using Microsoft.Extensions.Logging; |
| using SilkroadBot.Domain.Models; |
| using SilkroadBot.Plugins.SDK.Interfaces; |
|
|
| namespace SilkroadBot.AI.Guardrails; |
|
|
| |
| |
| |
| |
| |
| public class CommandValidator |
| { |
| private readonly ILogger<CommandValidator> _logger; |
| private readonly List<IValidationRule> _rules = new(); |
|
|
| public CommandValidator(ILogger<CommandValidator> logger) |
| { |
| _logger = logger; |
| RegisterDefaultRules(); |
| } |
|
|
| |
| |
| |
| public void RegisterRule(IValidationRule rule) |
| { |
| _rules.Add(rule); |
| _logger.LogDebug("Registered validation rule: {Name}", rule.Name); |
| } |
|
|
| |
| |
| |
| |
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot gameState) |
| { |
| if (!decision.IsValid) |
| return ValidationResult.Rejected("AI decision marked as invalid"); |
|
|
| foreach (var rule in _rules.OrderBy(r => r.Priority)) |
| { |
| var result = rule.Validate(decision, gameState); |
| if (!result.IsApproved) |
| { |
| _logger.LogWarning("Action '{Action}' rejected by rule '{Rule}': {Reason}", |
| decision.Action, rule.Name, result.Reason); |
| return result; |
| } |
| } |
|
|
| _logger.LogDebug("Action '{Action}' passed all validation rules", decision.Action); |
| return ValidationResult.Approved(); |
| } |
|
|
| private void RegisterDefaultRules() |
| { |
| RegisterRule(new DeathPreventionRule()); |
| RegisterRule(new PositionSafetyRule()); |
| RegisterRule(new HealthThresholdRule()); |
| RegisterRule(new CombatSafetyRule()); |
| RegisterRule(new ConfidenceThresholdRule()); |
| RegisterRule(new ActionExistsRule()); |
| } |
| } |
|
|
| |
| |
| |
| public interface IValidationRule |
| { |
| string Name { get; } |
| int Priority { get; } |
| ValidationResult Validate(AIDecision decision, GameStateSnapshot gameState); |
| } |
|
|
| |
| |
| |
| public record ValidationResult |
| { |
| public bool IsApproved { get; init; } |
| public string Reason { get; init; } = string.Empty; |
| public string? SuggestedAction { get; init; } |
| |
| public static ValidationResult Approved() => new() { IsApproved = true }; |
| public static ValidationResult Rejected(string reason) => new() { IsApproved = false, Reason = reason }; |
| public static ValidationResult RedirectTo(string action, string reason) => |
| new() { IsApproved = false, Reason = reason, SuggestedAction = action }; |
| } |
|
|
| |
|
|
| |
| |
| |
| public class DeathPreventionRule : IValidationRule |
| { |
| public string Name => "Death Prevention"; |
| public int Priority => 0; |
|
|
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot state) |
| { |
| if (state.IsDead && decision.Action != "resurrect" && decision.Action != "wait") |
| { |
| return ValidationResult.RedirectTo("wait", |
| "Character is dead. Only resurrect or wait actions are valid."); |
| } |
| return ValidationResult.Approved(); |
| } |
| } |
|
|
| |
| |
| |
| public class HealthThresholdRule : IValidationRule |
| { |
| public string Name => "Health Threshold"; |
| public int Priority => 10; |
| |
| private const double CriticalHPThreshold = 20.0; |
| private const double LowHPThreshold = 40.0; |
|
|
| private static readonly HashSet<string> _aggressiveActions = new() |
| { |
| "attack", "pull_monster", "engage", "skill_attack", "aoe_attack" |
| }; |
|
|
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot state) |
| { |
| if (state.HPPercentage < CriticalHPThreshold && _aggressiveActions.Contains(decision.Action)) |
| { |
| return ValidationResult.RedirectTo("heal", |
| $"HP is critically low ({state.HPPercentage:F1}%). Must heal before attacking."); |
| } |
| |
| if (state.HPPercentage < LowHPThreshold && decision.Action == "pull_monster") |
| { |
| return ValidationResult.RedirectTo("heal", |
| $"HP too low ({state.HPPercentage:F1}%) to pull new monsters."); |
| } |
| |
| return ValidationResult.Approved(); |
| } |
| } |
|
|
| |
| |
| |
| public class PositionSafetyRule : IValidationRule |
| { |
| public string Name => "Position Safety"; |
| public int Priority => 20; |
|
|
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot state) |
| { |
| |
| if (decision.Action == "move" && decision.Parameters.TryGetValue("distance", out var dist)) |
| { |
| if (dist is double distance && distance > 1000) |
| { |
| return ValidationResult.Rejected( |
| $"Movement distance ({distance}) exceeds safety limit. Potential hallucinated coordinates."); |
| } |
| } |
| return ValidationResult.Approved(); |
| } |
| } |
|
|
| |
| |
| |
| public class CombatSafetyRule : IValidationRule |
| { |
| public string Name => "Combat Safety"; |
| public int Priority => 15; |
|
|
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot state) |
| { |
| |
| if (state.IsInCombat && decision.Action == "pull_monster" && state.NearbyEntityCount > 5) |
| { |
| return ValidationResult.Rejected( |
| $"Already in combat with {state.NearbyEntityCount} nearby entities. Too dangerous to pull more."); |
| } |
| return ValidationResult.Approved(); |
| } |
| } |
|
|
| |
| |
| |
| public class ConfidenceThresholdRule : IValidationRule |
| { |
| public string Name => "Confidence Threshold"; |
| public int Priority => 5; |
| |
| private const double MinConfidence = 0.3; |
|
|
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot state) |
| { |
| if (decision.Confidence < MinConfidence) |
| { |
| return ValidationResult.RedirectTo("wait", |
| $"AI confidence ({decision.Confidence:F2}) below threshold ({MinConfidence}). Defaulting to safe action."); |
| } |
| return ValidationResult.Approved(); |
| } |
| } |
|
|
| |
| |
| |
| public class ActionExistsRule : IValidationRule |
| { |
| public string Name => "Action Exists"; |
| public int Priority => 1; |
| |
| private static readonly HashSet<string> _validActions = new() |
| { |
| "attack", "heal", "buff", "move", "wait", "loot", |
| "pull_monster", "skill_attack", "aoe_attack", "flee", |
| "resurrect", "use_potion", "equip", "sell", "buy", |
| "teleport", "party_join", "party_leave", "none" |
| }; |
|
|
| public ValidationResult Validate(AIDecision decision, GameStateSnapshot state) |
| { |
| if (!_validActions.Contains(decision.Action.ToLowerInvariant())) |
| { |
| return ValidationResult.RedirectTo("wait", |
| $"Unknown action '{decision.Action}'. Not in valid action set."); |
| } |
| return ValidationResult.Approved(); |
| } |
| } |
|
|