diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb71105 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Unit Tests + +on: + push: + branches: [main, master] + paths: + - 'Source/**' + - 'Tests/**' + - 'ArchTests/**' + - '.github/workflows/test.yml' + pull_request: + branches: [main, master] + paths: + - 'Source/**' + - 'Tests/**' + - 'ArchTests/**' + - '.github/workflows/test.yml' + +jobs: + test: + uses: RimWorld-RimMind-Mod/Workflows/.github/workflows/test.yml@main + secrets: inherit diff --git a/Source/Actions/ActionsToolArgumentAdapter.cs b/Source/Actions/ActionsToolArgumentAdapter.cs new file mode 100644 index 0000000..87595d1 --- /dev/null +++ b/Source/Actions/ActionsToolArgumentAdapter.cs @@ -0,0 +1,95 @@ +using System; +using Newtonsoft.Json.Linq; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Domain.Agent.Modes; + +namespace RimMind.Actions.Actions +{ + /// + /// Adapts raw arguments for RimMind-Actions composite tools + /// (such as crop/food designation inference and autonomous recovery reason defaults). + /// Registered via so Core has zero hard-coded Actions heuristics. + /// + public sealed class ActionsToolArgumentAdapter : IToolArgumentAdapter + { + public string Id => "actions_tool_argument_adapter"; + public string OwnerModId => "RimMindActions"; + + public bool CanAdapt(string toolId) + { + if (string.IsNullOrWhiteSpace(toolId)) return false; + return IsResourceDesignationTool(toolId) || IsAutonomousRecoveryTool(toolId); + } + + public JObject AdaptArguments(string toolId, JObject args, AgentDecision decision, int callerPawnId) + { + var obj = args ?? new JObject(); + + if (IsResourceDesignationTool(toolId) || + (!string.IsNullOrWhiteSpace(decision?.ActionIntent) && IsResourceDesignationTool(decision!.ActionIntent))) + { + AdaptResourceDesignationArguments(obj); + } + else if (IsAutonomousRecoveryTool(toolId) || + (!string.IsNullOrWhiteSpace(decision?.ActionIntent) && IsAutonomousRecoveryTool(decision!.ActionIntent))) + { + if (!obj.ContainsKey("reason")) + { + obj["reason"] = !string.IsNullOrEmpty(decision?.Reason) ? decision!.Reason : "autonomous recovery"; + } + } + + return obj; + } + + private static bool IsResourceDesignationTool(string toolId) + => string.Equals(toolId, "actions_designate_resources", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions.designate_resources", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "designate_resources", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "designate_map_order", StringComparison.OrdinalIgnoreCase); + + private static bool IsAutonomousRecoveryTool(string toolId) + => string.Equals(toolId, "actions.eat_and_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions_eat_and_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "eat_and_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "calm_recreation", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions.stabilize_rest", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "actions_stabilize_rest", StringComparison.OrdinalIgnoreCase) + || string.Equals(toolId, "stabilize_rest", StringComparison.OrdinalIgnoreCase); + + private static void AdaptResourceDesignationArguments(JObject obj) + { + string targetStr = (obj["target"]?.ToString() ?? obj["target_name"]?.ToString() ?? "").Trim(); + bool isFoodOrCropTarget = + targetStr.IndexOf("berr", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("food", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("edible", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("crop", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("rice", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("potato", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("corn", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("healroot", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("agave", StringComparison.OrdinalIgnoreCase) >= 0 || + targetStr.IndexOf("ambrosia", StringComparison.OrdinalIgnoreCase) >= 0; + + if (obj.TryGetValue("order_type", StringComparison.OrdinalIgnoreCase, out var otVal)) + { + string ot = otVal.ToString(); + if (ot.Equals("CutPlants", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = isFoodOrCropTarget ? "Harvest" : "ChopWood"; + else if (ot.Equals("ChopWood", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = "ChopWood"; + else if (ot.Equals("Harvest", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = "Harvest"; + else if (ot.Equals("Mine", StringComparison.OrdinalIgnoreCase)) + obj["designation_type"] = "Mine"; + else + obj["designation_type"] = ot; + } + else if (!obj.ContainsKey("designation_type")) + { + obj["designation_type"] = isFoodOrCropTarget ? "Harvest" : "ChopWood"; + } + } + } +} diff --git a/Source/Actions/AddWorktableBillCompositeTool.cs b/Source/Actions/AddWorktableBillCompositeTool.cs index ed1b4a0..e791099 100644 --- a/Source/Actions/AddWorktableBillCompositeTool.cs +++ b/Source/Actions/AddWorktableBillCompositeTool.cs @@ -3,11 +3,15 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -21,6 +25,24 @@ public sealed class AddWorktableBillCompositeTool : CompositeToolCallBase { public override string Id => "actions_add_worktable_bill"; + private static readonly string[] Aliases = new[] + { + "add_worktable_bill", + "actions.add_worktable_bill" + }; + + private static readonly string[] SummaryKeys = new[] + { + "recipe_def", + "worktable_def" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.WorktableBills; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Add Worktable Bill"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -31,6 +53,18 @@ public sealed class AddWorktableBillCompositeTool : CompositeToolCallBase public override IReadOnlyList RequiredToolIds => Array.Empty(); + protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + { + string? recipeDefName = root?["recipe_def"]?.ToString(); + if (string.IsNullOrWhiteSpace(recipeDefName) && args != null) + { + TryGetArgument(args.ArgumentsJson, "recipe_def", out recipeDefName); + } + return IsEssentialSurvivalRecipe(recipeDefName?.Trim()) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.WorktableBills; + } + public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) @@ -42,6 +76,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'recipe_def'."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.WorktableBills; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Added bill {recipeDefName}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -74,9 +122,10 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidDefName($"Recipe '{recipeDefName}' not found in database."))); } - var billAutonomyDomain = IsEssentialSurvivalRecipe(recipeDef.defName) - ? AutonomyDomain.EmergencySurvival - : AutonomyDomain.WorktableBills; + var billAutonomyDomain = ResolveAutonomyDomain(new JObject { ["recipe_def"] = recipeDef.defName }, args) + ?? (IsEssentialSurvivalRecipe(recipeDef.defName) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.WorktableBills); var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, billAutonomyDomain, args); if (!permCheck.IsOk) { @@ -209,6 +258,7 @@ public override Task> ExecuteAsync( modeDesc), args.ToolCallId, Id))); +#endif } private static bool IsEssentialSurvivalRecipe(string? recipeDefName) diff --git a/Source/Actions/CompositeToolCallBase.cs b/Source/Actions/CompositeToolCallBase.cs index a675106..feee626 100644 --- a/Source/Actions/CompositeToolCallBase.cs +++ b/Source/Actions/CompositeToolCallBase.cs @@ -6,13 +6,16 @@ using Newtonsoft.Json.Linq; using RimMind.Application.Common.Interfaces.Tools; using RimMind.Application.Common.Models.Tools; +using RimMind.Application.Features.Tools; +using RimMind.Domain.Agent.Policies; using RimMind.Domain.Common; +using RimMind.Domain.Enums; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; namespace RimMind.Actions.Actions { - public abstract class CompositeToolCallBase : ICompositeToolCall + public abstract class CompositeToolCallBase : ICompositeToolCall, IToolPolicyClassifier { public abstract string Id { get; } @@ -20,14 +23,103 @@ public abstract class CompositeToolCallBase : ICompositeToolCall public virtual string OwnerModId => "RimMindActions"; - public virtual ToolDefinition Definition => _cachedDefinition ??= BuildDefinition(); + protected virtual ToolKind ToolKind => ToolKind.Mutation; - protected virtual ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id }; + protected virtual AutonomyDomain? DefaultAutonomyDomain => null; + + protected virtual RiskLevel DefaultRiskLevel => MapDomainToRisk(DefaultAutonomyDomain, ToolKind); + + protected virtual bool DefaultRequiresApproval => false; + + protected virtual IReadOnlyList ToolAliases => Array.Empty(); + + protected virtual bool IsSelfTargetedTool => false; + + protected virtual string? DisplayName => null; + + protected virtual IReadOnlyList SummaryArgKeys => Array.Empty(); + + public virtual ToolManifest Manifest => new ToolManifest + { + OwnerModId = OwnerModId, + Kind = ToolKind, + DefaultDomain = DefaultAutonomyDomain, + AutonomyDomainId = DefaultAutonomyDomain?.ToString(), + RiskLevel = DefaultRiskLevel, + RequiresApproval = DefaultRequiresApproval, + Aliases = ToolAliases, + IsSelfTargeted = IsSelfTargetedTool, + DisplayName = DisplayName, + SummaryArgKeys = SummaryArgKeys + }; + + public virtual ToolDefinition Definition => _cachedDefinition ??= BuildDefinition() with + { + Manifest = Manifest + }; + + protected virtual ToolDefinition BuildDefinition() => new ToolDefinition + { + Id = Id, + Manifest = Manifest + }; public abstract IReadOnlyList RequiredToolIds { get; } public abstract Task> ExecuteAsync(ToolCallArgs args, CancellationToken ct); + protected virtual AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + => DefaultAutonomyDomain; + + public virtual ToolPolicyDecision Classify(ToolCallArgs args) + { + var effectiveArgs = args ?? new ToolCallArgs + { + ToolName = Id, + ArgumentsJson = "{}" + }; + var root = TryParseArgumentsObject(effectiveArgs.ArgumentsJson); + var domain = ResolveAutonomyDomain(root, effectiveArgs); + return new ToolPolicyDecision + { + Domain = domain, + DomainId = domain?.ToString() ?? Manifest.AutonomyDomainId, + Risk = MapDomainToRisk(domain, ToolKind), + RequiresApproval = Manifest.RequiresApproval + }; + } + + protected static JObject? TryParseArgumentsObject(string? argumentsJson) + { + if (string.IsNullOrWhiteSpace(argumentsJson)) return null; + try + { + return JToken.Parse(argumentsJson!) as JObject; + } + catch + { + return null; + } + } + + private static RiskLevel MapDomainToRisk(AutonomyDomain? domain, ToolKind kind) + { + if (!domain.HasValue) + { + return kind == ToolKind.Query ? RiskLevel.Low : RiskLevel.Medium; + } + + return domain.Value switch + { + AutonomyDomain.EmergencySurvival => RiskLevel.Low, + AutonomyDomain.ResourceMarking => RiskLevel.Low, + AutonomyDomain.WorktableBills => RiskLevel.Medium, + AutonomyDomain.BlueprintPlacement => RiskLevel.Medium, + AutonomyDomain.HighRiskOperations => RiskLevel.High, + _ => RiskLevel.Medium + }; + } + protected virtual IToolHandler? FindTool(string toolId) => RimMindAPI.Tools.FindById(toolId); /// @@ -75,23 +167,7 @@ protected async Task ExecuteAtomicAsync( } internal protected static bool TryParsePawnIdString(string? raw, out int parsedId) - { - parsedId = 0; - if (string.IsNullOrWhiteSpace(raw)) return false; - var s = raw!.Trim(); - if (s.StartsWith("NPC-", StringComparison.OrdinalIgnoreCase) || - s.StartsWith("pawn-", StringComparison.OrdinalIgnoreCase)) - { - s = s.Substring(s.IndexOf('-') + 1).Trim(); - } - else if (s.StartsWith("Pawn_", StringComparison.OrdinalIgnoreCase)) - { - int idx = s.Length - 1; - while (idx >= 0 && char.IsDigit(s[idx])) idx--; - s = s.Substring(idx + 1); - } - return int.TryParse(s, out parsedId); - } + => PawnIdParser.TryParse(raw, out parsedId); /// /// Checks whether trusted host code (e.g. an advisor approval card the player clicked) approved this call. @@ -163,7 +239,7 @@ internal protected static bool TryGetArgument(string argumentsJson, string ke } if (typeof(T) == typeof(int) && token.Type == JTokenType.String) { - if (TryParsePawnIdString(token.ToString(), out int parsedInt)) + if (PawnIdParser.TryParse(token.ToString(), out int parsedInt)) { value = (T)(object)parsedInt; return true; @@ -224,7 +300,7 @@ protected static Result TryGetPawnIdOrError( { int contextFallbackId = (args.PawnId.HasValue && args.PawnId.Value > 0) ? args.PawnId.Value - : (TryParsePawnIdString(args.NpcId, out int parsedNpcId) && parsedNpcId > 0 ? parsedNpcId : 0); + : (PawnIdParser.TryParse(args.NpcId, out int parsedNpcId) && parsedNpcId > 0 ? parsedNpcId : 0); if (TryGetArgument(args.ArgumentsJson, key, out int id) && id > 0) { diff --git a/Source/Actions/CompositeToolRegistrar.cs b/Source/Actions/CompositeToolRegistrar.cs index 0ecfbca..fb3ab64 100644 --- a/Source/Actions/CompositeToolRegistrar.cs +++ b/Source/Actions/CompositeToolRegistrar.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Reflection; using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Presentation.Api; using Verse; namespace RimMind.Actions.Actions @@ -43,6 +44,23 @@ public static void RegisterAll(IToolRegistry registry, Assembly assembly) Log.Warning($"[RimMind-Actions] Skipped {type.FullName}: {ex.Message}"); } } + + TryRegisterArgumentAdapter(); + } + + private static void TryRegisterArgumentAdapter() + { +#if !RIMMIND_ACTIONS_TESTS + try + { + var adapterRegistry = RimMindAPI.Ext.Get(); + adapterRegistry?.Register(new ActionsToolArgumentAdapter()); + } + catch + { + // Safe no-op when runtime extension registry is not initialized (e.g. isolated unit tests). + } +#endif } } } diff --git a/Source/Actions/DesignateResourcesCompositeTool.cs b/Source/Actions/DesignateResourcesCompositeTool.cs index cc1a3d6..394b7f5 100644 --- a/Source/Actions/DesignateResourcesCompositeTool.cs +++ b/Source/Actions/DesignateResourcesCompositeTool.cs @@ -6,8 +6,11 @@ using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -21,6 +24,25 @@ public sealed class DesignateResourcesCompositeTool : CompositeToolCallBase { public override string Id => "actions_designate_resources"; + private static readonly string[] Aliases = new[] + { + "designate_resources", + "designate_map_order", + "actions.designate_resources" + }; + + private static readonly string[] SummaryKeys = new[] + { + "designation_type", + "target_name" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.ResourceMarking; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Designate Resources"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,6 +64,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'designation_type' (Harvest, ChopWood, or Mine)."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Designated {designationTypeStr}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -59,7 +95,9 @@ public override Task> ExecuteAsync( // 1. Resolve Colonist Pawn and Permission Check Pawn? pawn = actorResolution.Pawn; - var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, AutonomyDomain.ResourceMarking, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, domain, args); if (!permCheck.IsOk) { return Task.FromResult(Result.Err(permCheck.Error)); @@ -300,8 +338,10 @@ public override Task> ExecuteAsync( targetSample), args.ToolCallId, Id))); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static bool IsGenericResourceFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) return true; @@ -331,5 +371,6 @@ private static bool IsGenericResourceFilter(string? filter) || f.Contains("_") || f.Any(c => c > 127); } +#endif } } diff --git a/Source/Actions/EatAndRecreationCompositeTool.cs b/Source/Actions/EatAndRecreationCompositeTool.cs index a894906..5db8e14 100644 --- a/Source/Actions/EatAndRecreationCompositeTool.cs +++ b/Source/Actions/EatAndRecreationCompositeTool.cs @@ -20,6 +20,27 @@ public sealed class EatAndRecreationCompositeTool : CompositeToolCallBase "pawn.job.set" }; + private static readonly string[] Aliases = new[] + { + "eat_and_recreation", + "calm_recreation", + "actions_eat_and_recreation", + "actions.calm_recreation", + "actions_calm_recreation" + }; + + private static readonly string[] SummaryKeys = new[] + { + "priority", + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Eat & Recreation"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,7 +63,9 @@ public override async Task> ExecuteAsync( } int pawnId = pawnIdResult.Value; - var guard = GuardColonistTarget(pawnId, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnId, domain, args); if (guard.IsErr) { return Result.Err(guard.Error); diff --git a/Source/Actions/EmergencyFleeCompositeTool.cs b/Source/Actions/EmergencyFleeCompositeTool.cs index d100b4f..55ac022 100644 --- a/Source/Actions/EmergencyFleeCompositeTool.cs +++ b/Source/Actions/EmergencyFleeCompositeTool.cs @@ -21,6 +21,24 @@ public sealed class EmergencyFleeCompositeTool : CompositeToolCallBase "pawn.draft.toggle" }; + private static readonly string[] Aliases = new[] + { + "emergency_flee", + "actions_emergency_flee" + }; + + private static readonly string[] SummaryKeys = new[] + { + "danger_level", + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Emergency Flee"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -43,7 +61,9 @@ public override async Task> ExecuteAsync( } int pawnId = pawnIdResult.Value; - var guard = GuardColonistTarget(pawnId, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnId, domain, args); if (guard.IsErr) { return Result.Err(guard.Error); diff --git a/Source/Actions/ManageEquipmentCompositeTool.cs b/Source/Actions/ManageEquipmentCompositeTool.cs index 85ad32a..100006f 100644 --- a/Source/Actions/ManageEquipmentCompositeTool.cs +++ b/Source/Actions/ManageEquipmentCompositeTool.cs @@ -7,9 +7,11 @@ using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; -using Verse; using Verse.AI; +#endif +using Verse; namespace RimMind.Actions.Actions { @@ -21,6 +23,27 @@ public sealed class ManageEquipmentCompositeTool : CompositeToolCallBase { public override string Id => "actions_manage_equipment"; + private static readonly string[] Aliases = new[] + { + "manage_equipment", + "equip_combat_gear", + "actions.manage_equipment", + "actions.equip_combat_gear", + "actions_equip_combat_gear" + }; + + private static readonly string[] SummaryKeys = new[] + { + "target_apparel_def", + "purpose" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Manage Equipment"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -35,6 +58,21 @@ public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) { +#if RIMMIND_ACTIONS_TESTS + var pawnIdResult = TryGetPawnIdOrError(args, "pawn_id"); + if (pawnIdResult.IsErr) + { + return Task.FromResult(Result.Err(pawnIdResult.Error)); + } + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnIdResult.Value, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + return Task.FromResult(Result.Ok(ToolResult.Ok("ok", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -58,7 +96,9 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Colonist pawn not found or invalid."))); } - var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, domain, args); if (!permCheck.IsOk) { return Task.FromResult(Result.Err(permCheck.Error)); @@ -169,6 +209,7 @@ bool CanWearApparel(Apparel a) => pawn.LabelShort, $"{ambientTemp:F1}", bestApparel.LabelCap), args.ToolCallId, Id))); +#endif } } } diff --git a/Source/Actions/ManageZoneCompositeTool.cs b/Source/Actions/ManageZoneCompositeTool.cs index f28dde3..2df8719 100644 --- a/Source/Actions/ManageZoneCompositeTool.cs +++ b/Source/Actions/ManageZoneCompositeTool.cs @@ -6,8 +6,11 @@ using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -21,6 +24,24 @@ public sealed class ManageZoneCompositeTool : CompositeToolCallBase { public override string Id => "actions_manage_zone"; + private static readonly string[] Aliases = new[] + { + "manage_zone", + "actions.manage_zone" + }; + + private static readonly string[] SummaryKeys = new[] + { + "zone_type", + "crop_def" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.ResourceMarking; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Manage Zone"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,6 +63,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'zone_type' (Growing or Stockpile)."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Zone {zoneTypeStr}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -59,7 +94,9 @@ public override Task> ExecuteAsync( // 1. Resolve Anchor Colonist & Autonomy Permission Guard Pawn? pawn = actorResolution.Pawn; - var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, AutonomyDomain.ResourceMarking, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.ResourceMarking; + var permCheck = ActionPermissionGuard.CheckPermission(pawn, Id, domain, args); if (!permCheck.IsOk) { return Task.FromResult(Result.Err(permCheck.Error)); @@ -184,8 +221,10 @@ public override Task> ExecuteAsync( bestRect.CenterCell.x, bestRect.CenterCell.z, details), args.ToolCallId, Id))); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static int SearchBestZoneRect(Map map, IntVec3 origin, int width, int height, bool isGrowing, out CellRect bestRect) { bestRect = default; @@ -223,5 +262,6 @@ private static int SearchBestZoneRect(Map map, IntVec3 origin, int width, int he return bestValidCells; } +#endif } } diff --git a/Source/Actions/PlaceBlueprintCompositeTool.cs b/Source/Actions/PlaceBlueprintCompositeTool.cs index d0d6100..8e6a82b 100644 --- a/Source/Actions/PlaceBlueprintCompositeTool.cs +++ b/Source/Actions/PlaceBlueprintCompositeTool.cs @@ -4,14 +4,18 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; -using Verse; using Verse.AI; +#endif +using Verse; namespace RimMind.Actions.Actions { @@ -23,6 +27,24 @@ public sealed class PlaceBlueprintCompositeTool : CompositeToolCallBase { public override string Id => "actions_place_blueprint"; + private static readonly string[] Aliases = new[] + { + "place_blueprint", + "actions.place_blueprint" + }; + + private static readonly string[] SummaryKeys = new[] + { + "building_def", + "stuff_def" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.BlueprintPlacement; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Place Blueprint"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -33,6 +55,18 @@ public sealed class PlaceBlueprintCompositeTool : CompositeToolCallBase public override IReadOnlyList RequiredToolIds => Array.Empty(); + protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + { + string? buildingDefName = root?["building_def"]?.ToString(); + if (string.IsNullOrWhiteSpace(buildingDefName) && args != null) + { + TryGetArgument(args.ArgumentsJson, "building_def", out buildingDefName); + } + return IsEssentialSurvivalStructure(buildingDefName?.Trim()) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.BlueprintPlacement; + } + public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) @@ -44,6 +78,20 @@ public override Task> ExecuteAsync( RimMindErrors.InvalidAction(Id, "Missing required parameter 'building_def'."))); } +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.BlueprintPlacement; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok($"Placed {buildingDefName}", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: true); if (actorResolution.Error != null) { @@ -120,9 +168,10 @@ public override Task> ExecuteAsync( Pawn? anchorPawn = actorResolution.Pawn; // Autonomy Permission & Resource Safety Guards - var autonomyDomain = IsEssentialSurvivalStructure(thingDef.defName) - ? AutonomyDomain.EmergencySurvival - : AutonomyDomain.BlueprintPlacement; + var autonomyDomain = ResolveAutonomyDomain(new JObject { ["building_def"] = thingDef.defName }, args) + ?? (IsEssentialSurvivalStructure(thingDef.defName) + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.BlueprintPlacement); var permCheck = ActionPermissionGuard.CheckPermission(anchorPawn, Id, autonomyDomain, args); if (!permCheck.IsOk) { @@ -239,8 +288,10 @@ public override Task> ExecuteAsync( Content = JsonConvert.SerializeObject(successPayload, Formatting.Indented), IsError = false })); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static IntVec3 FindValidPlacementCell(Map map, ThingDef def, IntVec3 anchor, string hint, ref Rot4 rot, ThingDef? stuff) { bool wantsIndoor = hint.Contains("indoor") || hint.Contains("bed") || hint.Contains("room"); @@ -358,6 +409,7 @@ private static IntVec3 FindValidPlacementCell(Map map, ThingDef def, IntVec3 anc return null; } +#endif private static bool IsEssentialSurvivalStructure(string? defName) { diff --git a/Source/Actions/PlanRoomProjectCompositeTool.cs b/Source/Actions/PlanRoomProjectCompositeTool.cs index 87b5caf..5901bc6 100644 --- a/Source/Actions/PlanRoomProjectCompositeTool.cs +++ b/Source/Actions/PlanRoomProjectCompositeTool.cs @@ -4,14 +4,18 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using RimMind.Actions.Policies; using RimMind.Application.Common.Models.Tools; using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; -using Verse; using Verse.AI; +#endif +using Verse; namespace RimMind.Actions.Actions { @@ -24,6 +28,23 @@ public sealed class PlanRoomProjectCompositeTool : CompositeToolCallBase { public override string Id => "actions_plan_room_project"; + private static readonly string[] Aliases = new[] + { + "plan_room_project", + "actions.plan_room_project" + }; + + private static readonly string[] SummaryKeys = new[] + { + "room_type" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.BlueprintPlacement; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Plan Room Project"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -34,10 +55,44 @@ public sealed class PlanRoomProjectCompositeTool : CompositeToolCallBase public override IReadOnlyList RequiredToolIds => Array.Empty(); + protected override AutonomyDomain? ResolveAutonomyDomain(JObject? root, ToolCallArgs args) + { + string? roomType = root?["room_type"]?.ToString(); + if (string.IsNullOrWhiteSpace(roomType) && args != null) + { + TryGetArgument(args.ArgumentsJson, "room_type", out roomType); + } + if (string.IsNullOrWhiteSpace(roomType)) + { + roomType = "Shelter"; + } + + string normalized = roomType!.Trim(); + bool isSurvivalRoom = normalized.Equals("Shelter", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("Bedroom", StringComparison.OrdinalIgnoreCase); + return isSurvivalRoom + ? AutonomyDomain.EmergencySurvival + : AutonomyDomain.BlueprintPlacement; + } + public override Task> ExecuteAsync( ToolCallArgs args, CancellationToken ct) { +#if RIMMIND_ACTIONS_TESTS + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.BlueprintPlacement; + if (TryGetArgument(args.ArgumentsJson, "pawn_id", out int testPawnId) && testPawnId > 0) + { + var guard = GuardColonistTarget(testPawnId, domain, args); + if (guard.IsErr) + { + return Task.FromResult(Result.Err(guard.Error)); + } + } + return Task.FromResult(Result.Ok( + ToolResult.Ok("Planned room", args.ToolCallId, Id))); +#else var actorResolution = ActionPermissionGuard.ResolveActorAndMap(args, "pawn_id", fallbackToAnyActiveColonist: false); if (actorResolution.Error != null) { @@ -69,9 +124,8 @@ public override Task> ExecuteAsync( bool isSurvivalRoom = roomType.Equals("Shelter", StringComparison.OrdinalIgnoreCase) || roomType.Equals("Bedroom", StringComparison.OrdinalIgnoreCase); - var roomAutonomyDomain = isSurvivalRoom - ? AutonomyDomain.EmergencySurvival - : AutonomyDomain.BlueprintPlacement; + var roomAutonomyDomain = ResolveAutonomyDomain(new JObject { ["room_type"] = roomType }, args) + ?? (isSurvivalRoom ? AutonomyDomain.EmergencySurvival : AutonomyDomain.BlueprintPlacement); var permCheck = ActionPermissionGuard.CheckPermission(builderPawn, Id, roomAutonomyDomain, args); if (!permCheck.IsOk) @@ -253,8 +307,10 @@ public override Task> ExecuteAsync( planMsg, args.ToolCallId, Id))); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static bool TryFindClearRoomRect(Map map, IntVec3 origin, int size, ThingDef? selectedStuff, out CellRect bestRect) { bestRect = default; @@ -299,5 +355,6 @@ private static bool TryFindClearRoomRect(Map map, IntVec3 origin, int size, Thin } return false; } +#endif } } diff --git a/Source/Actions/QueryBuildableStructuresCompositeTool.cs b/Source/Actions/QueryBuildableStructuresCompositeTool.cs index e4e9c81..b3b6282 100644 --- a/Source/Actions/QueryBuildableStructuresCompositeTool.cs +++ b/Source/Actions/QueryBuildableStructuresCompositeTool.cs @@ -5,8 +5,11 @@ using System.Threading.Tasks; using Newtonsoft.Json; using RimMind.Application.Common.Models.Tools; +using RimMind.Domain.Agent.Policies; using RimMind.Domain.ValueObjects; +#if !RIMMIND_ACTIONS_TESTS using RimWorld; +#endif using Verse; namespace RimMind.Actions.Actions @@ -19,6 +22,24 @@ public sealed class QueryBuildableStructuresCompositeTool : CompositeToolCallBas { public override string Id => "actions_query_buildable_structures"; + private static readonly string[] Aliases = new[] + { + "query_buildable_structures", + "actions.query_buildable_structures" + }; + + private static readonly string[] SummaryKeys = new[] + { + "category" + }; + + protected override ToolKind ToolKind => ToolKind.Query; + protected override AutonomyDomain? DefaultAutonomyDomain => null; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => false; + protected override string? DisplayName => "Query Buildable Structures"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -36,6 +57,15 @@ public override Task> ExecuteAsync( TryGetArgument(args.ArgumentsJson, "category", out var categoryFilter); categoryFilter = string.IsNullOrWhiteSpace(categoryFilter) ? "all" : categoryFilter.ToLowerInvariant().Trim(); +#if RIMMIND_ACTIONS_TESTS + return Task.FromResult(Result.Ok(new ToolResult + { + ToolCallId = args.ToolCallId, + ToolName = Id, + Content = JsonConvert.SerializeObject(new { status = "ok", category = categoryFilter }), + IsError = false + })); +#else // Read-only query: prefer the acting colonist's map, fall back to the viewed map. var map = RimMind.Actions.Policies.ActionPermissionGuard.ResolveActorAndMap(args).Map ?? Find.CurrentMap; if (map == null) @@ -123,8 +153,10 @@ public override Task> ExecuteAsync( Content = JsonConvert.SerializeObject(responsePayload), IsError = false })); +#endif } +#if !RIMMIND_ACTIONS_TESTS private static bool HasActivePowerGrid(Map map) { if (map.powerNetManager == null) return false; @@ -196,6 +228,7 @@ private static bool CheckCanAfford(ThingDef def, int wood, int steel, int comp, if (comp > 0 && compHave < comp) return false; return true; } +#endif private static List GetCuratedStructures() { diff --git a/Source/Actions/StabilizeRestCompositeTool.cs b/Source/Actions/StabilizeRestCompositeTool.cs index ffda842..11cd80d 100644 --- a/Source/Actions/StabilizeRestCompositeTool.cs +++ b/Source/Actions/StabilizeRestCompositeTool.cs @@ -17,6 +17,23 @@ public sealed class StabilizeRestCompositeTool : CompositeToolCallBase "pawn.job.set" }; + private static readonly string[] Aliases = new[] + { + "stabilize_rest", + "actions_stabilize_rest" + }; + + private static readonly string[] SummaryKeys = new[] + { + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Force Rest"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -39,7 +56,9 @@ public override async Task> ExecuteAsync( } int pawnId = pawnIdResult.Value; - var guard = GuardColonistTarget(pawnId, AutonomyDomain.EmergencySurvival, args); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var guard = GuardColonistTarget(pawnId, domain, args); if (guard.IsErr) { return Result.Err(guard.Error); diff --git a/Source/Actions/TriagePatientCompositeTool.cs b/Source/Actions/TriagePatientCompositeTool.cs index d383961..b87bc58 100644 --- a/Source/Actions/TriagePatientCompositeTool.cs +++ b/Source/Actions/TriagePatientCompositeTool.cs @@ -20,6 +20,24 @@ public sealed class TriagePatientCompositeTool : CompositeToolCallBase "pawn.job.set" }; + private static readonly string[] Aliases = new[] + { + "triage_patient", + "actions_triage_patient" + }; + + private static readonly string[] SummaryKeys = new[] + { + "urgency", + "reason" + }; + + protected override AutonomyDomain? DefaultAutonomyDomain => AutonomyDomain.EmergencySurvival; + protected override IReadOnlyList ToolAliases => Aliases; + protected override bool IsSelfTargetedTool => true; + protected override string? DisplayName => "Triage Patient"; + protected override IReadOnlyList SummaryArgKeys => SummaryKeys; + protected override ToolDefinition BuildDefinition() => new ToolDefinition { Id = Id, @@ -42,7 +60,9 @@ public override async Task> ExecuteAsync( } int patientId = patientIdResult.Value; - var patientGuard = GuardColonistTarget(patientId, AutonomyDomain.EmergencySurvival, args, "patient_id"); + var domain = ResolveAutonomyDomain(TryParseArgumentsObject(args.ArgumentsJson), args) + ?? AutonomyDomain.EmergencySurvival; + var patientGuard = GuardColonistTarget(patientId, domain, args, "patient_id"); if (patientGuard.IsErr) { return Result.Err(patientGuard.Error); @@ -52,7 +72,7 @@ public override async Task> ExecuteAsync( bool hasDoctor = TryGetArgument(args.ArgumentsJson, "doctor_id", out doctorId) && doctorId > 0; if (hasDoctor) { - var doctorGuard = GuardColonistTarget(doctorId, AutonomyDomain.EmergencySurvival, args, "doctor_id"); + var doctorGuard = GuardColonistTarget(doctorId, domain, args, "doctor_id"); if (doctorGuard.IsErr) { return Result.Err(doctorGuard.Error); diff --git a/Tests/CompositeToolRegistrarTests.cs b/Tests/CompositeToolRegistrarTests.cs index 97bd823..ce4140f 100644 --- a/Tests/CompositeToolRegistrarTests.cs +++ b/Tests/CompositeToolRegistrarTests.cs @@ -79,11 +79,11 @@ public void RegisterAll_Duplicate_Id_Logs_Warning_And_Skips_Second() { Verse.Log.Messages.Clear(); var registry = new FakeRegistry(); - // RegisterAll scans the test assembly which contains both StabilizeRestCompositeTool - // and DuplicateIdCompositeTool — both declare Id "actions.stabilize_rest". + // RegisterAll scans the test assembly which contains all 11 production composite tools + // plus DuplicateIdCompositeTool — which duplicates Id "actions.stabilize_rest". CompositeToolRegistrar.RegisterAll(registry, typeof(DuplicateIdCompositeTool).Assembly); - Assert.Equal(4, registry.RegisteredHandlers.Count); + Assert.Equal(11, registry.RegisteredHandlers.Count); Assert.Contains(Verse.Log.Messages, m => m.Contains("duplicate Id") && m.Contains("actions.stabilize_rest")); } diff --git a/Tests/CompositeToolSelfDescriptionContracts.cs b/Tests/CompositeToolSelfDescriptionContracts.cs new file mode 100644 index 0000000..376365a --- /dev/null +++ b/Tests/CompositeToolSelfDescriptionContracts.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using RimMind.Actions.Actions; +using RimMind.Application.Common.Interfaces.Tools; +using RimMind.Application.Common.Models.Tools; +using RimMind.Application.Features.Tools; +using RimMind.Domain.Agent.Modes; +using RimMind.Domain.Agent.Policies; +using RimMind.Domain.Enums; +using Xunit; + +namespace RimMind.Actions.Tests +{ + public class CompositeToolSelfDescriptionContracts + { + private static readonly CompositeToolCallBase[] AllProductionCompositeTools = new CompositeToolCallBase[] + { + new PlaceBlueprintCompositeTool(), + new PlanRoomProjectCompositeTool(), + new AddWorktableBillCompositeTool(), + new DesignateResourcesCompositeTool(), + new ManageZoneCompositeTool(), + new ManageEquipmentCompositeTool(), + new StabilizeRestCompositeTool(), + new EmergencyFleeCompositeTool(), + new EatAndRecreationCompositeTool(), + new TriagePatientCompositeTool(), + new QueryBuildableStructuresCompositeTool() + }; + + [Fact] + public void All_11_CompositeTools_Declare_RimMindActions_Owner_Valid_Domains_And_Aliases() + { + var registry = new ToolRegistry(); + foreach (var tool in AllProductionCompositeTools) + { + registry.Register(tool); + + var manifest = tool.Definition.Manifest; + Assert.NotNull(manifest); + Assert.Equal("RimMindActions", manifest.OwnerModId); + Assert.False(string.IsNullOrWhiteSpace(manifest.DisplayName), $"Tool '{tool.Id}' must declare DisplayName"); + Assert.NotEmpty(manifest.Aliases); + Assert.NotEmpty(manifest.SummaryArgKeys); + + if (tool is QueryBuildableStructuresCompositeTool) + { + Assert.Equal(ToolKind.Query, manifest.Kind); + Assert.Null(manifest.DefaultDomain); + } + else + { + Assert.Equal(ToolKind.Mutation, manifest.Kind); + Assert.NotNull(manifest.DefaultDomain); + Assert.Equal(manifest.DefaultDomain.Value.ToString(), manifest.AutonomyDomainId); + } + + // Canonical ID and every declared alias must resolve back to the tool handler in ToolRegistry + Assert.Same(tool, registry.FindById(tool.Id)); + foreach (var alias in manifest.Aliases) + { + Assert.Same(tool, registry.FindById(alias)); + } + } + } + + [Fact] + public void SelfTargeted_Metadata_Matches_PawnPersonal_Tools() + { + var selfTargetedIds = AllProductionCompositeTools + .Where(t => t.Manifest.IsSelfTargeted) + .Select(t => t.Id) + .OrderBy(id => id) + .ToList(); + + Assert.Contains("actions.stabilize_rest", selfTargetedIds); + Assert.Contains("actions.eat_and_recreation", selfTargetedIds); + Assert.Contains("actions.emergency_flee", selfTargetedIds); + Assert.Contains("actions.triage_patient", selfTargetedIds); + Assert.Contains("actions_manage_equipment", selfTargetedIds); + Assert.DoesNotContain("actions_place_blueprint", selfTargetedIds); + Assert.DoesNotContain("actions_designate_resources", selfTargetedIds); + } + + [Fact] + public void PolicyClassifier_Classifies_Survival_Vs_NonSurvival_Arguments_Identically_To_Runtime() + { + var placeBlueprint = new PlaceBlueprintCompositeTool(); + var bedPolicy = placeBlueprint.Classify(new ToolCallArgs + { + ToolName = placeBlueprint.Id, + ArgumentsJson = "{\"building_def\":\"Bed\"}" + }); + var statuePolicy = placeBlueprint.Classify(new ToolCallArgs + { + ToolName = placeBlueprint.Id, + ArgumentsJson = "{\"building_def\":\"SculptureSmall\"}" + }); + Assert.Equal(AutonomyDomain.EmergencySurvival, bedPolicy.Domain); + Assert.Equal(RiskLevel.Low, bedPolicy.Risk); + Assert.Equal(AutonomyDomain.BlueprintPlacement, statuePolicy.Domain); + Assert.Equal(RiskLevel.Medium, statuePolicy.Risk); + + var addBill = new AddWorktableBillCompositeTool(); + var mealPolicy = addBill.Classify(new ToolCallArgs + { + ToolName = addBill.Id, + ArgumentsJson = "{\"recipe_def\":\"CookMealSimple\"}" + }); + var sculptureBillPolicy = addBill.Classify(new ToolCallArgs + { + ToolName = addBill.Id, + ArgumentsJson = "{\"recipe_def\":\"Make_SculptureSmall\"}" + }); + Assert.Equal(AutonomyDomain.EmergencySurvival, mealPolicy.Domain); + Assert.Equal(RiskLevel.Low, mealPolicy.Risk); + Assert.Equal(AutonomyDomain.WorktableBills, sculptureBillPolicy.Domain); + Assert.Equal(RiskLevel.Medium, sculptureBillPolicy.Risk); + + var planRoom = new PlanRoomProjectCompositeTool(); + var shelterPolicy = planRoom.Classify(new ToolCallArgs + { + ToolName = planRoom.Id, + ArgumentsJson = "{\"room_type\":\"Shelter\"}" + }); + var storagePolicy = planRoom.Classify(new ToolCallArgs + { + ToolName = planRoom.Id, + ArgumentsJson = "{\"room_type\":\"Storage\"}" + }); + Assert.Equal(AutonomyDomain.EmergencySurvival, shelterPolicy.Domain); + Assert.Equal(RiskLevel.Low, shelterPolicy.Risk); + Assert.Equal(AutonomyDomain.BlueprintPlacement, storagePolicy.Domain); + Assert.Equal(RiskLevel.Medium, storagePolicy.Risk); + } + + [Fact] + public void ActionsToolArgumentAdapter_Adapts_CropDesignations_And_RecoveryReason() + { + var adapter = new ActionsToolArgumentAdapter(); + Assert.True(adapter.CanAdapt("actions_designate_resources")); + Assert.True(adapter.CanAdapt("designate_map_order")); + Assert.True(adapter.CanAdapt("actions.stabilize_rest")); + Assert.False(adapter.CanAdapt("actions_place_blueprint")); + + var berryArgs = adapter.AdaptArguments( + "actions_designate_resources", + JObject.Parse("{\"order_type\":\"CutPlants\",\"target\":\"Plant_Berry\"}"), + new AgentDecision { ActionIntent = "designate_map_order" }, + 1); + Assert.Equal("Harvest", berryArgs["designation_type"]?.ToString()); + + var oakArgs = adapter.AdaptArguments( + "actions_designate_resources", + JObject.Parse("{\"order_type\":\"CutPlants\",\"target\":\"Plant_TreeOak\"}"), + new AgentDecision { ActionIntent = "designate_map_order" }, + 1); + Assert.Equal("ChopWood", oakArgs["designation_type"]?.ToString()); + + var restArgs = adapter.AdaptArguments( + "actions.stabilize_rest", + new JObject(), + new AgentDecision { ActionIntent = "stabilize_rest", Reason = "Exhausted after raid" }, + 1); + Assert.Equal("Exhausted after raid", restArgs["reason"]?.ToString()); + } + } +} diff --git a/Tests/RimMindActions.Tests.csproj b/Tests/RimMindActions.Tests.csproj index 9d0525b..cfdb8e1 100644 --- a/Tests/RimMindActions.Tests.csproj +++ b/Tests/RimMindActions.Tests.csproj @@ -18,16 +18,23 @@ + + + + + + + @@ -38,10 +45,18 @@ 测试通过 CompositeToolRegistrationTests 用源文本断言注册行为) --> + + + + + + + +