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/Core/DialogueContextChangedEvent.cs b/Source/Core/DialogueContextChangedEvent.cs new file mode 100644 index 0000000..61f6991 --- /dev/null +++ b/Source/Core/DialogueContextChangedEvent.cs @@ -0,0 +1,39 @@ +using System; +using RimMind.Domain.Events; +using RimMind.Domain.ValueObjects; +using RimMind.Presentation.Api; + +namespace RimMind.Dialogue.Core +{ + /// + /// Published on the Core bus when a pawn's dialogue partner changes or a dialogue reply was + /// applied (thoughts/relation updated). The cached dialogue_state and + /// dialogue_relation context keys name as their invalidation + /// trigger, so the prompt never describes the previous recipient or stale dialogue thoughts. + /// + public sealed class DialogueContextChangedEvent : AgentBusEvent + { + /// Bus event name used by context-key invalidation triggers. + public const string EventName = "DialogueEvent"; + + public DialogueContextChangedEvent(int pawnId) + { + PawnId = pawnId; + NpcId = pawnId > 0 ? "NPC-" + pawnId : string.Empty; + } + + /// Registers with the Core bus. Call before registering context keys. + internal static void Register() + { + try { RimMindAPI.Bus.RegisterEventType(EventName); } + catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Dialogue] Event type registration failed: {ex.Message}"); } + } + + /// Publishes from any thread; never throws. + internal static void Publish(int pawnId) + { + try { RimMindAPI.Bus.PublishOnMainThread(new DialogueContextChangedEvent(pawnId)); } + catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Dialogue] Dialogue change notification failed: {ex.Message}"); } + } + } +} diff --git a/Source/Core/DialogueContextProviderRegistrar.cs b/Source/Core/DialogueContextProviderRegistrar.cs index 80d2005..e46e470 100644 --- a/Source/Core/DialogueContextProviderRegistrar.cs +++ b/Source/Core/DialogueContextProviderRegistrar.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using RimMind.Application.Common.Constants; using RimMind.Application.Common.Interfaces.Context; +using RimMind.Application.Common.Models.Context; +using RimMind.Application.Features.Context; using RimMind.Domain.Common; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; @@ -16,15 +18,7 @@ namespace RimMind.Dialogue.Core internal static class DialogueContextProviderRegistrar { private static string Tr(string key, params object[] args) - { - try - { - if (string.IsNullOrEmpty(key)) return string.Empty; - string template = key.CanTranslate() ? key.TranslateSimple() : key; - return (args == null || args.Length == 0) ? template : string.Format(template, args); - } - catch { return key ?? string.Empty; } - } + => RimMindText.TrOr(key, key ?? string.Empty, args); /// /// Context providers run on pipeline thread-pool threads; pawn memories, relations, dialogue @@ -35,6 +29,11 @@ private static string Tr(string key, params object[] args) internal static void RegisterAll() { + // Register the typed event before any key names it, so the trigger resolves + // to a real event (unknown names never fall back to the base bus event). + DialogueContextChangedEvent.Register(); + RegisterScenarios(); + RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "dialogue_state", ContextLayer.L3_State, 0.2f, (ctx, ct) => @@ -42,7 +41,7 @@ internal static void RegisterAll() if (ctx.PawnId <= 0) return Task.FromResult(null); int pawnId = ctx.PawnId; return OnMainThread(() => BuildStateContext(pawnId), ct); - }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 750, invalidationTriggers: new[] { "DialogueEvent" })); + }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 750, invalidationTriggers: new[] { DialogueContextChangedEvent.EventName })); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "dialogue_relation", ContextLayer.L3_State, 0.15f, @@ -51,7 +50,7 @@ internal static void RegisterAll() if (ctx.PawnId <= 0) return Task.FromResult(null); int pawnId = ctx.PawnId; return OnMainThread(() => BuildRelationContext(pawnId), ct); - }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 750, invalidationTriggers: new[] { "DialogueEvent" })); + }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 750, invalidationTriggers: new[] { DialogueContextChangedEvent.EventName })); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "dialogue_task", ContextLayer.L0_Static, 0.95f, @@ -60,8 +59,9 @@ internal static void RegisterAll() if (ctx.PawnId <= 0) return Task.FromResult(null); if (ctx.Scenario != RimMindAPI.Context.ScenarioDialogue) return Task.FromResult(null); string? speakerName = ctx.Hints?.TryGetValue("SpeakerName", out var sn) == true ? sn?.ToString() : null; - if (!string.IsNullOrEmpty(speakerName)) return Task.FromResult(null); - bool isMonologue = ctx.Hints?.TryGetValue("IsMonologue", out var im) == true && im is bool b && b; + bool isMonologue = string.IsNullOrEmpty(speakerName) + && ctx.Hints?.TryGetValue("IsMonologue", out var im) == true + && ((im is bool b && b) || (im is string imStr && bool.TryParse(imStr, out var parsed) && parsed)); var subKeys = new List { "Role", "Process", "Constraint", "Fallback", "ThoughtRules" }; subKeys.Add(isMonologue ? "GoalMonologue" : "GoalDialogue"); subKeys.Add(isMonologue ? "ExampleMonologue" : "ExampleDialogue"); @@ -72,7 +72,7 @@ internal static void RegisterAll() return OnMainThread( () => RimMindAPI.Prompt.BuildTaskInstruction("RimMind.Dialogue.Prompt.TaskInstruction", null, keys), ct); - }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 0, invalidationTriggers: new[] { "DialogueEvent" })); + }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 0)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "dialogue_history", ContextLayer.L4_History, 0.5f, @@ -81,7 +81,86 @@ internal static void RegisterAll() if (ctx.PawnId <= 0) return Task.FromResult(null); int pawnId = ctx.PawnId; return OnMainThread(() => BuildHistoryContext(pawnId), ct); - }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 0, invalidationTriggers: new[] { "DialogueEvent" })); + }, RimMindOwnerConsts.DialogueModId, stalenessTicks: 0)); + } + + private static void RegisterScenarios() + { + ScenarioRegistry.Register(new ScenarioDescriptor + { + Id = ScenarioIds.Dialogue, + DefaultBaseRounds = 10, + Description = "RimMind.Application.Scenario.Dialogue", + DefaultBudget = 0.6f, + L4Mode = L4Mode.BudgetControlled, + DefaultExcludeKeys = new[] { "combat_status", "task_progress" }, + OwnerModId = RimMindOwnerConsts.DialogueModId, + RelevanceWeights = new Dictionary(StringComparer.Ordinal) + { + ["health"] = 0.6f, + ["mood"] = 0.9f, + ["current_job"] = 0.5f, + ["combat_status"] = 0.4f, + ["target_info"] = 0.3f, + ["task_progress"] = 0.3f, + ["nearby_pawns"] = 0.8f, + ["colony_status"] = 0.5f, + ["current_area"] = 0.6f, + ["weather"] = 0.3f, + ["time_of_day"] = 0.4f, + ["season"] = 0.2f, + ["map_structure"] = 0.2f, + ["pawn_base_info"] = 0.7f, + ["fixed_relations"] = 0.85f, + ["ideology"] = 0.6f, + ["skills_summary"] = 0.4f, + ["memory_pawn"] = 0.7f, + ["working_memory"] = 0.6f, + ["memory_narrator"] = 0.3f, + ["dialogue_state"] = 0.9f, + ["dialogue_relation"] = 0.95f, + ["dialogue_history"] = 0.85f, + ["dialogue_task"] = 0.95f + } + }); + + ScenarioRegistry.Register(new ScenarioDescriptor + { + Id = ScenarioIds.CrisisDialogue, + DefaultBaseRounds = 8, + Description = "RimMind.Application.Scenario.CrisisDialogue", + DefaultBudget = 0.7f, + L4Mode = L4Mode.BudgetControlled, + DefaultExcludeKeys = new[] { "task_progress" }, + OwnerModId = RimMindOwnerConsts.DialogueModId, + RelevanceWeights = new Dictionary(StringComparer.Ordinal) + { + ["health"] = 0.9f, + ["mood"] = 1.0f, + ["current_job"] = 0.6f, + ["combat_status"] = 0.8f, + ["target_info"] = 0.4f, + ["task_progress"] = 0.2f, + ["nearby_pawns"] = 0.85f, + ["colony_status"] = 0.6f, + ["current_area"] = 0.6f, + ["weather"] = 0.4f, + ["time_of_day"] = 0.3f, + ["season"] = 0.2f, + ["map_structure"] = 0.3f, + ["pawn_base_info"] = 0.8f, + ["fixed_relations"] = 0.9f, + ["ideology"] = 0.7f, + ["skills_summary"] = 0.4f, + ["memory_pawn"] = 0.8f, + ["working_memory"] = 0.85f, + ["memory_narrator"] = 0.3f, + ["dialogue_state"] = 0.95f, + ["dialogue_relation"] = 0.85f, + ["dialogue_history"] = 0.5f, + ["dialogue_task"] = 0.95f + } + }); } /// Main thread only. @@ -178,9 +257,9 @@ internal static void RegisterAll() relevant.Reverse(); var sb = new StringBuilder(); - sb.AppendLine("RimMind.Dialogue.Context.HistoryHeader".CanTranslate() - ? "RimMind.Dialogue.Context.HistoryHeader".Translate().ToString() - : "【Recent Dialogue History】(Strictly forbidden to mechanically repeat the same complaint or phrase. Expand on new work details or mutual care):"); + sb.AppendLine(RimMindText.TrOr( + "RimMind.Dialogue.Context.HistoryHeader", + "【Recent Dialogue History】(Strictly forbidden to mechanically repeat the same complaint or phrase. Expand on new work details or mutual care):")); foreach (var entry in relevant) { diff --git a/Source/Core/DialogueRequestCoordinator.cs b/Source/Core/DialogueRequestCoordinator.cs index 6e3c50a..62bdf9f 100644 --- a/Source/Core/DialogueRequestCoordinator.cs +++ b/Source/Core/DialogueRequestCoordinator.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using RimMind.Application.Common.Constants; using RimMind.Application.Common.Models.Context; using RimMind.Application.Features.Llm; using RimMind.Dialogue.Settings; @@ -112,7 +113,7 @@ public void HandleTrigger( return "Dialogue startup delay is active."; if (_reservations.IsPawnPending(pawn.thingIDNumber)) return "A dialogue request is already pending for this pawn."; - if (RimMindAPI.ShouldSkipDialogue(pawn, type.ToString())) + if (RimMindAPI.ShouldSkipDialogue(pawn, ToTriggerKind(type))) return "AI dialogue condition is not met."; if (DialogueFlowPolicy.UsesMonologueCooldown( @@ -144,6 +145,17 @@ public void HandleTrigger( return null; } + private static string ToTriggerKind(DialogueTriggerType type) => type switch + { + DialogueTriggerType.Chitchat => DialogueTriggerKinds.Chitchat, + DialogueTriggerType.Auto => DialogueTriggerKinds.Auto, + DialogueTriggerType.PlayerInput => DialogueTriggerKinds.PlayerInput, + DialogueTriggerType.Hediff => DialogueTriggerKinds.Hediff, + DialogueTriggerType.LevelUp => DialogueTriggerKinds.LevelUp, + DialogueTriggerType.Thought => DialogueTriggerKinds.Thought, + _ => type.ToString() + }; + public void TryTriggerReply( Pawn originalSender, Pawn replier, @@ -241,9 +253,15 @@ void ReportFailure(string error, bool showAutomaticError = true) reservationId, recipient); RimMind.Presentation.Api.RimMindPawnLookup.CachePawn(recipient); + // The active recipient feeds dialogue_relation; drop the cached copy now. + DialogueContextChangedEvent.Publish(pawn.thingIDNumber); } string formattedContext = FormatContext(type, context, recipient); + bool isMonologue = DialogueFlowPolicy.IsMonologue(type, recipient != null); + string? speakerName = recipient != null + ? (recipient.Name?.ToStringShort ?? recipient.LabelShort) + : (type == DialogueTriggerType.PlayerInput ? "Player" : null); string npcId = $"NPC-{pawn.thingIDNumber}"; bool isCrisis = pawn.InMentalState || (pawn.needs?.mood != null && pawn.needs.mood.CurLevel < 0.20f); @@ -258,26 +276,36 @@ void ReportFailure(string error, bool showAutomaticError = true) : $"[CRISIS BREAKDOWN: {mentalReason}] Speaker is currently suffering a mental break or severe distress. Do NOT engage in calm polite small talk. Output an intense, raw, emotional monologue or confrontation reflecting extreme distress, agony, panic, or rage."; gameState.AddSection("crisis_expression", crisisSection); } + string autoTriggerInstruction = "RimMind.Dialogue.Prompt.AutoTrigger".CanTranslate() + ? "RimMind.Dialogue.Prompt.AutoTrigger".Translate().ToString() + : "Based on your current state and surroundings, respond naturally."; + string triggerPayload = string.IsNullOrWhiteSpace(formattedContext) + ? autoTriggerInstruction + : $"{formattedContext}\n{autoTriggerInstruction}"; gameState.AddSection( type == DialogueTriggerType.PlayerInput ? "dialogue_input" : "dialogue_trigger", type == DialogueTriggerType.PlayerInput ? context - : ("RimMind.Dialogue.Prompt.AutoTrigger".CanTranslate() - ? "RimMind.Dialogue.Prompt.AutoTrigger".Translate().ToString() - : "Based on your current state and surroundings, respond naturally.")); + : triggerPayload); - var envelope = LlmRequestEnvelopeBuilder + var envelopeBuilder = LlmRequestEnvelopeBuilder .ForNpc( npcId, gameStateInfo: gameState) .ForScenarioId(isCrisis ? ScenarioIds.CrisisDialogue : ScenarioIds.Dialogue) .WithModId("RimMind.Dialogue") + .WithHint("IsMonologue", isMonologue ? "true" : "false") + .WithHint("TriggerContext", formattedContext) .WithTools(DialogueToolDefinitions.AsList()) .WithToolDispatchMode(ToolCallDispatchMode.Manual) .WithMaxTokens(800) .WithTemperature(type == DialogueTriggerType.PlayerInput ? 0.85f : 0.8f) - .WithCancellation(lifetime.Token) - .Build(); + .WithCancellation(lifetime.Token); + if (!string.IsNullOrEmpty(speakerName)) + { + envelopeBuilder.WithHint("SpeakerName", speakerName); + } + var envelope = envelopeBuilder.Build(); RimMindAPI.Request.Send(envelope, result => { @@ -308,6 +336,8 @@ void ReportFailure(string error, bool showAutomaticError = true) type, isReply); if (!string.IsNullOrWhiteSpace(speech)) { + // Reply applied thoughts/relation changes that feed dialogue context. + DialogueContextChangedEvent.Publish(pawn.thingIDNumber); Notify(onReply, speech!); } else @@ -349,15 +379,25 @@ private static string FormatContext( string formatted = type switch { DialogueTriggerType.Chitchat => - "RimMind.Dialogue.Prompt.Context.Chitchat".Translate(context), + "RimMind.Dialogue.Prompt.Context.Chitchat".CanTranslate() + ? "RimMind.Dialogue.Prompt.Context.Chitchat".Translate(context).ToString() + : $"[Chitchat: {context}]", DialogueTriggerType.Hediff => - "RimMind.Dialogue.Prompt.Context.Hediff".Translate(context), + "RimMind.Dialogue.Prompt.Context.Hediff".CanTranslate() + ? "RimMind.Dialogue.Prompt.Context.Hediff".Translate(context).ToString() + : $"[Health condition: {context}]", DialogueTriggerType.LevelUp => - "RimMind.Dialogue.Prompt.Context.LevelUp".Translate(context), + "RimMind.Dialogue.Prompt.Context.LevelUp".CanTranslate() + ? "RimMind.Dialogue.Prompt.Context.LevelUp".Translate(context).ToString() + : $"[Skill level up: {context}]", DialogueTriggerType.Thought => - "RimMind.Dialogue.Prompt.Context.Thought".Translate(context), + "RimMind.Dialogue.Prompt.Context.Thought".CanTranslate() + ? "RimMind.Dialogue.Prompt.Context.Thought".Translate(context).ToString() + : $"[Recent thought: {context}]", DialogueTriggerType.Auto => - "RimMind.Dialogue.Prompt.Context.Auto".Translate(context), + "RimMind.Dialogue.Prompt.Context.Auto".CanTranslate() + ? "RimMind.Dialogue.Prompt.Context.Auto".Translate(context).ToString() + : context, _ => context }; @@ -366,11 +406,13 @@ private static string FormatContext( : GetRecipientRoleKey(recipient); if (recipient != null && roleKey != null) { - formatted += "\n" - + "RimMind.Dialogue.Prompt.Context.Recipient" - .Translate(recipient.Name.ToStringShort) - + "\n" - + roleKey.Translate(); + string recipientLine = "RimMind.Dialogue.Prompt.Context.Recipient".CanTranslate() + ? "RimMind.Dialogue.Prompt.Context.Recipient".Translate(recipient.Name.ToStringShort).ToString() + : $"[Recipient: {recipient.Name.ToStringShort}]"; + string roleLine = roleKey.CanTranslate() + ? roleKey.Translate().ToString() + : roleKey; + formatted += "\n" + recipientLine + "\n" + roleLine; } return formatted; diff --git a/Source/Extensions/DialogueModCooldown.cs b/Source/Extensions/DialogueModCooldown.cs index a366bb8..3e7bb9d 100644 --- a/Source/Extensions/DialogueModCooldown.cs +++ b/Source/Extensions/DialogueModCooldown.cs @@ -1,7 +1,6 @@ +using RimMind.Application.Common.Helpers; using RimMind.Application.Common.Interfaces.Extension; -using RimMind.Dialogue.Settings; using RimMind.Presentation.Api; -using UnityEngine; namespace RimMind.Dialogue { @@ -9,15 +8,6 @@ internal sealed class DialogueModCooldown : IModCooldown { public string Id => "Dialogue"; public string OwnerModId => "RimMind.Dialogue"; - public int CooldownTicks - { - get - { - float scale = RimMindAPI.Settings.ActivityFrequencyScale; - float multiplier = scale > 0.01f ? (1.0f / scale) : 1.0f; - multiplier = Mathf.Clamp(multiplier, 0.35f, 3.5f); - return Mathf.RoundToInt(1500 * multiplier); - } - } + public int CooldownTicks => ActivityCooldownScaler.Scale(1500, RimMindAPI.Settings.ActivityFrequencyScale); } } diff --git a/Source/Patches/GameLoadPatch.cs b/Source/Patches/GameLoadPatch.cs index 415bfb2..279fddf 100644 --- a/Source/Patches/GameLoadPatch.cs +++ b/Source/Patches/GameLoadPatch.cs @@ -9,6 +9,7 @@ public static class GameLoadPatch public static void ClearTriggerCooldowns() { ThoughtPatch.LastTriggeredTicks.Clear(); + HediffPatch.LastTriggerTickByPawn.Clear(); } } diff --git a/Source/Patches/HediffPatch.cs b/Source/Patches/HediffPatch.cs index acc53af..bfff5f3 100644 --- a/Source/Patches/HediffPatch.cs +++ b/Source/Patches/HediffPatch.cs @@ -1,7 +1,8 @@ using System; -using System.Collections.Generic; using System.Reflection; using HarmonyLib; +using RimMind.Application.Common.Helpers; +using RimMind.Application.Features.GameEvents; using RimMind.Dialogue.Core; using RimMind.Dialogue.Settings; using RimMind.Presentation.Api; @@ -18,8 +19,7 @@ public static class HediffPatch public const int HediffCooldownTicks = 2500; private static readonly FieldInfo? PawnField = AccessTools.Field(typeof(Pawn_HealthTracker), "pawn"); - private static readonly object CooldownLock = new object(); - private static readonly Dictionary LastTriggerTickByPawn = new Dictionary(); + internal static readonly KeyedCooldownGate LastTriggerTickByPawn = new KeyedCooldownGate(); public static bool ShouldTriggerHediffDialogue( bool isBad, @@ -31,8 +31,16 @@ public static bool ShouldTriggerHediffDialogue( int? lastTriggerTick, int cooldownTicks = HediffCooldownTicks) { - if (!isBad && !isTendable && !makesSickThought) return false; - if (!isLifeThreatening && severity < MinHediffSeverity) return false; + if (!GameEventSignificancePolicy.IsSignificantInjury( + isBad, + isTendable, + makesSickThought, + isLifeThreatening, + severity, + MinHediffSeverity)) + { + return false; + } if (lastTriggerTick.HasValue && nowTick >= lastTriggerTick.Value && (nowTick - lastTriggerTick.Value) < cooldownTicks) @@ -69,25 +77,22 @@ public static void Postfix(Pawn_HealthTracker __instance, Hediff hediff) int now = Find.TickManager?.TicksGame ?? 0; int cooldown = Math.Max(HediffCooldownTicks, settings.monologueCooldownTicks); - lock (CooldownLock) + if (!LastTriggerTickByPawn.TryEnter(pawn.thingIDNumber, now, cooldown)) { - int? lastTick = LastTriggerTickByPawn.TryGetValue(pawn.thingIDNumber, out int recorded) ? recorded : (int?)null; - if (!ShouldTriggerHediffDialogue( - hediff.def.isBad, - hediff.def.tendable, - hediff.def.makesSickThought, - hediff.Severity, - isLifeThreatening, - now, - lastTick, - cooldown)) - { - return; - } - LastTriggerTickByPawn[pawn.thingIDNumber] = now; + return; } string label = hediff.def.label ?? hediff.def.defName ?? string.Empty; + RimMindAPI.GameEvents.Publish(new HediffAddedGameEvent( + pawn.thingIDNumber, + hediff.def.defName ?? string.Empty, + label, + hediff.Severity, + hediff.def.isBad, + hediff.def.tendable, + hediff.def.makesSickThought, + isLifeThreatening, + hediff.Part?.Label)); string context = "RimMind.Dialogue.Context.HediffOccurred".CanTranslate() ? (string)"RimMind.Dialogue.Context.HediffOccurred".Translate(label) : label; diff --git a/Source/Patches/SkillLearnPatch.cs b/Source/Patches/SkillLearnPatch.cs index 2bb5016..5473f25 100644 --- a/Source/Patches/SkillLearnPatch.cs +++ b/Source/Patches/SkillLearnPatch.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using RimMind.Application.Features.GameEvents; using RimMind.Dialogue.Core; using RimMind.Dialogue.Settings; using RimMind.Presentation.Api; @@ -25,7 +26,12 @@ public static void Postfix(SkillRecord __instance, float xp, int __state) Pawn pawn = __instance.Pawn; if (!RimMindPawnLookup.IsEligibleColonist(pawn) || pawn.Downed) return; - string context = "RimMind.Dialogue.Context.SkillLevelUp".Translate(__instance.def.label, __instance.Level); + RimMindAPI.GameEvents.Publish(new SkillLevelUpGameEvent( + pawn.thingIDNumber, + __instance.def?.defName ?? string.Empty, + __state, + __instance.Level)); + string context = "RimMind.Dialogue.Context.SkillLevelUp".Translate(__instance.def!.label, __instance.Level); RimMindDialogueService.HandleTrigger(pawn, context, DialogueTriggerType.LevelUp, null); } } diff --git a/Source/Patches/ThoughtPatch.cs b/Source/Patches/ThoughtPatch.cs index e02d7ac..16d4d42 100644 --- a/Source/Patches/ThoughtPatch.cs +++ b/Source/Patches/ThoughtPatch.cs @@ -1,5 +1,6 @@ using System; using HarmonyLib; +using RimMind.Application.Common.Helpers; using RimMind.Dialogue.Core; using RimMind.Dialogue.Settings; using RimMind.Presentation.Api; @@ -13,40 +14,10 @@ namespace RimMind.Dialogue.Patches new Type[] { typeof(Thought_Memory), typeof(Pawn) })] public static class ThoughtPatch { - internal static readonly System.Collections.Generic.Dictionary<(int PawnId, string ThoughtDefName), int> LastTriggeredTicks = - new System.Collections.Generic.Dictionary<(int PawnId, string ThoughtDefName), int>(); internal const int SameThoughtCooldownTicks = 60000; // 1 in-game day internal const int MaxTrackedThoughtEntries = 256; - - internal static void PruneIfNeeded(int nowTick) - { - if (LastTriggeredTicks.Count <= MaxTrackedThoughtEntries) return; - - var toRemove = new System.Collections.Generic.List<(int PawnId, string ThoughtDefName)>(); - foreach (var kvp in LastTriggeredTicks) - { - if (nowTick < kvp.Value || (nowTick - kvp.Value) >= SameThoughtCooldownTicks) - { - toRemove.Add(kvp.Key); - } - } - - for (int i = 0; i < toRemove.Count; i++) - { - LastTriggeredTicks.Remove(toRemove[i]); - } - - if (LastTriggeredTicks.Count > MaxTrackedThoughtEntries) - { - var ordered = new System.Collections.Generic.List>(LastTriggeredTicks); - ordered.Sort((a, b) => a.Value.CompareTo(b.Value)); - int excess = LastTriggeredTicks.Count - MaxTrackedThoughtEntries; - for (int i = 0; i < excess; i++) - { - LastTriggeredTicks.Remove(ordered[i].Key); - } - } - } + internal static readonly KeyedCooldownGate<(int PawnId, string ThoughtDefName)> LastTriggeredTicks = + new KeyedCooldownGate<(int PawnId, string ThoughtDefName)>(MaxTrackedThoughtEntries); [HarmonyPostfix] public static void Postfix(MemoryThoughtHandler __instance, Thought_Memory newThought) @@ -69,12 +40,10 @@ public static void Postfix(MemoryThoughtHandler __instance, Thought_Memory newTh int nowTick = Find.TickManager?.TicksGame ?? 0; var key = (pawn.thingIDNumber, newThought.def.defName); - if (LastTriggeredTicks.TryGetValue(key, out int lastTick) && nowTick >= lastTick && (nowTick - lastTick) < SameThoughtCooldownTicks) + if (!LastTriggeredTicks.TryEnter(key, nowTick, SameThoughtCooldownTicks)) { return; } - LastTriggeredTicks[key] = nowTick; - PruneIfNeeded(nowTick); string rawLabel = newThought.LabelCap ?? newThought.def.label ?? newThought.def.defName; string cleanLabel = System.Text.RegularExpressions.Regex.Replace( diff --git a/Source/Thoughts/Thought_RimMindDialogue.cs b/Source/Thoughts/Thought_RimMindDialogue.cs index dccd379..a8f37fe 100644 --- a/Source/Thoughts/Thought_RimMindDialogue.cs +++ b/Source/Thoughts/Thought_RimMindDialogue.cs @@ -1,3 +1,4 @@ +using RimMind.Presentation.Api; using RimWorld; using Verse; @@ -19,20 +20,9 @@ public override string LabelCap { text = mappedKey.Translate().ToString(); } - else if (aiLabel.CanTranslate()) - { - text = aiLabel.Translate().ToString(); - } - else if (aiLabel.StartsWith("RimMind.")) - { - int lastDot = aiLabel.LastIndexOf('.'); - text = lastDot >= 0 && lastDot < aiLabel.Length - 1 - ? aiLabel.Substring(lastDot + 1) - : aiLabel; - } else { - text = aiLabel; + text = RimMindAPI.Thoughts.FormatCleanLabel(aiLabel); } return $"[RimMind] {text.CapitalizeFirst()}"; } diff --git a/Tests/Contracts/DialogueRequestLifecycleContracts.cs b/Tests/Contracts/DialogueRequestLifecycleContracts.cs index 1a060d8..d3c8565 100644 --- a/Tests/Contracts/DialogueRequestLifecycleContracts.cs +++ b/Tests/Contracts/DialogueRequestLifecycleContracts.cs @@ -449,6 +449,65 @@ public void Crisis_dialogue_uses_localized_crisis_keys_with_english_fallback() } } + [Fact] + public async System.Threading.Tasks.Task Automatic_triggers_and_context_provider_preserve_monologue_and_speaker_hints() + { + Verse.Extensions.UntranslatableKeys.Add("RimMind.Dialogue.Prompt.Context.Hediff"); + Verse.Extensions.UntranslatableKeys.Add("RimMind.Dialogue.Prompt.Context.Chitchat"); + try + { + RimMindDialogueService.HandleTrigger(_pawn, "gunshot wound (torso)", DialogueTriggerType.Hediff, null); + var monoEnvelope = Assert.Single(RimMindAPI.Sent).Envelope; + Assert.NotNull(monoEnvelope.Hints); + Assert.Equal("true", monoEnvelope.Hints!["IsMonologue"]); + Assert.False(monoEnvelope.Hints.ContainsKey("SpeakerName")); + Assert.Contains("gunshot wound (torso)", monoEnvelope.GameStateInfo!.ToString(), StringComparison.Ordinal); + Assert.Contains("RimMind.Dialogue.Prompt.AutoTrigger", monoEnvelope.GameStateInfo.ToString(), StringComparison.Ordinal); + Complete(0, "ouch"); + + RimMindAPI.Sent.Clear(); + RimMindDialogueService.HandleTrigger(_pawn, "nice weather", DialogueTriggerType.Chitchat, _recipient); + var chatEnvelope = Assert.Single(RimMindAPI.Sent).Envelope; + Assert.NotNull(chatEnvelope.Hints); + Assert.Equal("false", chatEnvelope.Hints!["IsMonologue"]); + Assert.True(chatEnvelope.Hints.ContainsKey("SpeakerName")); + Assert.Contains("nice weather", chatEnvelope.GameStateInfo!.ToString(), StringComparison.Ordinal); + Complete(0, "indeed"); + } + finally + { + Verse.Extensions.UntranslatableKeys.Clear(); + } + + DialogueContextProviderRegistrar.RegisterAll(); + var taskDef = RimMindAPI.Context.ContextKeys.Definitions.Single(d => d.Key == "dialogue_task"); + var monoCtx = new RimMind.Application.Common.Interfaces.Context.ProviderContext(RimMindAPI.Context.ScenarioDialogue, "t1") + { + PawnId = 1, + NpcId = "NPC-1", + Hints = new System.Collections.Generic.Dictionary { ["IsMonologue"] = "true" } + }; + string? monoTask = await taskDef.Provider(monoCtx, CancellationToken.None); + Assert.NotNull(monoTask); + Assert.Contains("GoalMonologue", monoTask!, StringComparison.Ordinal); + Assert.DoesNotContain("RelationDelta", monoTask, StringComparison.Ordinal); + + var speakerCtx = new RimMind.Application.Common.Interfaces.Context.ProviderContext(RimMindAPI.Context.ScenarioDialogue, "t2") + { + PawnId = 1, + NpcId = "NPC-1", + Hints = new System.Collections.Generic.Dictionary + { + ["IsMonologue"] = "false", + ["SpeakerName"] = "Player" + } + }; + string? speakerTask = await taskDef.Provider(speakerCtx, CancellationToken.None); + Assert.NotNull(speakerTask); + Assert.Contains("GoalDialogue", speakerTask!, StringComparison.Ordinal); + Assert.Contains("RelationDelta", speakerTask, StringComparison.Ordinal); + } + private static void Complete(int index, string content) { string? toolCalls = string.IsNullOrWhiteSpace(content) diff --git a/Tests/DialogueBoundaryStubs.cs b/Tests/DialogueBoundaryStubs.cs index 341169c..0369bdd 100644 --- a/Tests/DialogueBoundaryStubs.cs +++ b/Tests/DialogueBoundaryStubs.cs @@ -30,6 +30,18 @@ public static void PublishPerception(int pawnId, string type, string text, float public static class Settings { public static float ActivityFrequencyScale { get; set; } = 1.0f; + public static int ScaleCooldown(int baseCooldownTicks, int minFloorTicks = 0) + => RimMind.Application.Common.Helpers.ActivityCooldownScaler.Scale(baseCooldownTicks, ActivityFrequencyScale, minFloorTicks); + } + public static class Thoughts + { + public static string FormatCleanLabel(string rawTagOrKey, string defaultPrefixToStrip = "RimMind.") + { + if (string.IsNullOrEmpty(rawTagOrKey)) return string.Empty; + if (UnityData.IsInMainThread && rawTagOrKey.CanTranslate()) + return rawTagOrKey.Translate().ToString(); + return RimMind.Application.Common.Models.Psychology.ThoughtSpec.FormatCleanLabelFallback(rawTagOrKey, defaultPrefixToStrip); + } } public static class Request { @@ -51,6 +63,47 @@ public static bool AddPawnMemory(string content, MemoryKind kind, int tick, return true; } } + public static class Bus + { + public static void RegisterEventType(string name) where T : RimMind.Domain.Events.AgentBusEvent + => RimMind.Application.Features.AgentBus.AgentBusImpl.RegisterKnownEventType(name, typeof(T)); + public static void PublishOnMainThread(T evt) where T : RimMind.Domain.Events.AgentBusEvent { } + } + public static class MainThreadSnapshot + { + public static RimMind.Application.Common.Threading.MainThreadReader Reader { get; set; } = + new RimMind.Application.Common.Threading.MainThreadReader(a => a(), () => true); + + public static System.Threading.Tasks.Task GetAsync( + Func readOnMainThread, + T fallback = default!, + TimeSpan? timeout = null, + System.Threading.CancellationToken ct = default) + => Reader.ReadAsync( + readOnMainThread, + fallback, + timeout ?? RimMind.Application.Common.Threading.MainThreadReader.DefaultTimeout, + ct); + } + public static class Context + { + public static string ScenarioDialogue => RimMind.Application.Common.Models.Context.ScenarioIds.Dialogue; + public static TestContextRegistry ContextKeys { get; } = new(); + } + public sealed class TestContextRegistry + { + public List Definitions { get; } = new(); + public void Register(RimMind.Application.Common.Interfaces.Context.ContextProviderDef definition) + { + Definitions.RemoveAll(d => d.Key == definition.Key); + Definitions.Add(definition); + } + } + public static class Prompt + { + public static string BuildTaskInstruction(string prefix, object? args, params string[] subKeys) + => string.Join("\n", System.Linq.Enumerable.Select(subKeys, key => prefix + "." + key)); + } } public static class RimMindPawnLookup diff --git a/Tests/RimMindDialogue.Tests.csproj b/Tests/RimMindDialogue.Tests.csproj index 26c073f..d752b94 100644 --- a/Tests/RimMindDialogue.Tests.csproj +++ b/Tests/RimMindDialogue.Tests.csproj @@ -22,6 +22,7 @@ + @@ -37,6 +38,8 @@ + + diff --git a/Tests/RimWorldStubs.cs b/Tests/RimWorldStubs.cs index 9565c0a..c8f07f8 100644 --- a/Tests/RimWorldStubs.cs +++ b/Tests/RimWorldStubs.cs @@ -30,7 +30,6 @@ public class Pawn : Thing public class MentalStateDef : Def { - public string? label; } public class NameTriple @@ -49,6 +48,19 @@ public static class RestUtility public class Pawn_RelationsTracker { public int OpinionOf(Verse.Pawn other) => 0; + public float CompatibilityWith(Verse.Pawn other) => 0.5f; + public float SecondaryRomanceChanceFactor(Verse.Pawn other) => 0f; + public System.Collections.Generic.List DirectRelations { get; } = new(); + } + + public class DirectPawnRelation + { + public PawnRelationDef? def; + public Verse.Pawn? otherPawn; + } + + public class PawnRelationDef : Verse.Def + { } public static class LovePartnerRelationUtility @@ -64,7 +76,6 @@ public class Job public class JobDef : Verse.Def { public object? joyKind; - public string? label; } public static class JobDefOf @@ -93,6 +104,7 @@ public class ThoughtHandler public class MemoryThoughtHandler { + public System.Collections.Generic.List Memories { get; } = new(); public void TryGainMemory(Thought_Memory thought) { } } @@ -114,6 +126,7 @@ public virtual void ExposeData() { } // Thought_Memory 存根 public class Thought_Memory : Thought { + public int DurationTicks = 60000; } // Thought_MemorySocial 存根 diff --git a/Tests/VerseStubs.cs b/Tests/VerseStubs.cs index efe9a20..d12ef61 100644 --- a/Tests/VerseStubs.cs +++ b/Tests/VerseStubs.cs @@ -33,6 +33,7 @@ public static string CapitalizeFirst(this string s) => // RimWorld 翻译扩展方法存根,测试中直接返回原字符串 public static TaggedString Translate(this string s) => new TaggedString { Value = s }; + public static string TranslateSimple(this string s) => s; public static readonly HashSet UntranslatableKeys = new HashSet(); @@ -48,6 +49,7 @@ public static TaggedString Translate(this string s, params object[] args) => public class Def : IExposable { public string defName = ""; + public string? label; public virtual void ExposeData() { } }