From 676738cd2f9f4e74c9dd7202d1d483ebde855dcc Mon Sep 17 00:00:00 2001
From: mcocdaa <2021137961@qq.com>
Date: Sat, 7 Nov 2026 04:31:50 +0100
Subject: [PATCH] feat(dialogue): scenario registration, GameEventHub &
ThoughtSpec migration, and CI test workflow
feat(dialogue): wire context invalidation events and scenario registration
- Add DialogueContextChangedEvent and publish it when the dialogue
partner changes or a reply is applied, so cached dialogue_state and
dialogue_relation context keys invalidate instead of describing a
stale recipient or old dialogue thoughts.
- Register dialogue and crisis-dialogue scenario descriptors with
relevance weights from the dialogue mod.
- Pass IsMonologue/SpeakerName/TriggerContext hints to the LLM envelope
and include trigger context in automatic-trigger prompts; accept
string-typed IsMonologue hints in the task provider.
- Publish HediffAdded and SkillLevelUp game events from patches.
- Replace ad-hoc cooldown dictionaries with KeyedCooldownGate and reuse
ActivityCooldownScaler for the dialogue cooldown.
- Add fallbacks for untranslated prompt strings and delegate thought
label cleanup to RimMindAPI.Thoughts.
- Add unit test workflow and contract tests for monologue/speaker hints.
---
.github/workflows/test.yml | 22 ++++
Source/Core/DialogueContextChangedEvent.cs | 39 ++++++
.../Core/DialogueContextProviderRegistrar.cs | 115 +++++++++++++++---
Source/Core/DialogueRequestCoordinator.cs | 76 +++++++++---
Source/Extensions/DialogueModCooldown.cs | 14 +--
Source/Patches/GameLoadPatch.cs | 1 +
Source/Patches/HediffPatch.cs | 45 ++++---
Source/Patches/SkillLearnPatch.cs | 8 +-
Source/Patches/ThoughtPatch.cs | 39 +-----
Source/Thoughts/Thought_RimMindDialogue.cs | 14 +--
.../DialogueRequestLifecycleContracts.cs | 59 +++++++++
Tests/DialogueBoundaryStubs.cs | 53 ++++++++
Tests/RimMindDialogue.Tests.csproj | 3 +
Tests/RimWorldStubs.cs | 17 ++-
Tests/VerseStubs.cs | 2 +
15 files changed, 390 insertions(+), 117 deletions(-)
create mode 100644 .github/workflows/test.yml
create mode 100644 Source/Core/DialogueContextChangedEvent.cs
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() { }
}