From eb4080129b31c9bdbf720d1d2a11bb645947067e Mon Sep 17 00:00:00 2001
From: mcocdaa <2021137961@qq.com>
Date: Sat, 7 Nov 2026 04:32:05 +0100
Subject: [PATCH] feat(memory): scenario registration, GameEventHub & TrOr
migration, and CI test workflow
feat(memory): publish memory and game events for context invalidation
Add a typed MemoryChangedEvent on the Core bus and publish it whenever
pawn or narrator memories are added, merged or cleared, so the
memory_pawn and memory_narrator context keys invalidate immediately
instead of waiting for staleness. Register the event type before any
key references it, and register a Memory scenario descriptor with
relevance weights for the memory context keys.
Publish HediffAddedGameEvent and SkillLevelUpGameEvent from the trigger
patches, and route injury significance checks through
GameEventSignificancePolicy. Replace the hand-rolled injury cooldown
dictionary with KeyedCooldownGate and skip the cooldown for
life-threatening injuries.
Delegate translation lookups to RimMindText.TrOr, reuse JsonRepairer
for truncated JSON with a guarded fallback, and add a unit test
workflow plus test project references for the Core application and
text helpers.
---
.github/workflows/test.yml | 22 +++++++
Source/Core/TimeFormatter.cs | 11 +---
.../DarkMemory/DarkMemoryResultParserPure.cs | 27 ++++++++
Source/Data/RimMindMemoryWorldComponent.cs | 4 ++
Source/Injection/MemoryChangedEvent.cs | 39 ++++++++++++
Source/Injection/MemoryContextProvider.cs | 55 +++++++++++------
Source/Injection/WorkingMemoryProvider.cs | 12 +---
Source/Triggers/Patch_AddHediff.cs | 61 ++++++++-----------
Source/Triggers/Patch_SkillLevelUp.cs | 18 +++---
Tests/RimMindMemory.Tests.csproj | 2 +
Tests/VerseStubs.cs | 5 ++
11 files changed, 176 insertions(+), 80 deletions(-)
create mode 100644 .github/workflows/test.yml
create mode 100644 Source/Injection/MemoryChangedEvent.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/TimeFormatter.cs b/Source/Core/TimeFormatter.cs
index fe2e45f..f8c0608 100644
--- a/Source/Core/TimeFormatter.cs
+++ b/Source/Core/TimeFormatter.cs
@@ -1,5 +1,6 @@
using System;
using RimMind.Domain.Common;
+using RimMind.Presentation.Api;
using Verse;
namespace RimMind.Memory.Core
@@ -10,15 +11,7 @@ public static class TimeFormatter
private const int TicksPerDay = RimMindTime.TicksPerDay;
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);
public static string FormatTimeAgo(int eventTick, int nowTick)
{
diff --git a/Source/DarkMemory/DarkMemoryResultParserPure.cs b/Source/DarkMemory/DarkMemoryResultParserPure.cs
index 275962f..ba32f0d 100644
--- a/Source/DarkMemory/DarkMemoryResultParserPure.cs
+++ b/Source/DarkMemory/DarkMemoryResultParserPure.cs
@@ -3,6 +3,7 @@
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
+using RimMind.Application.Features.Json;
namespace RimMind.Memory.DarkMemory
{
@@ -57,6 +58,32 @@ public class DarkMemoryResultDto
private static string? TryRepairTruncatedJson(string input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
+ if (HasUnclosedString(input)) return null;
+ return JsonRepairer.TryRepairTruncatedJson(input) ?? TryRepairFallback(input);
+ }
+
+ private static bool HasUnclosedString(string input)
+ {
+ bool inString = false;
+ bool escaped = false;
+ foreach (char c in input)
+ {
+ if (inString)
+ {
+ if (escaped) escaped = false;
+ else if (c == '\\') escaped = true;
+ else if (c == '"') inString = false;
+ }
+ else if (c == '"')
+ {
+ inString = true;
+ }
+ }
+ return inString;
+ }
+
+ private static string? TryRepairFallback(string input)
+ {
try
{
JToken.Parse(input);
diff --git a/Source/Data/RimMindMemoryWorldComponent.cs b/Source/Data/RimMindMemoryWorldComponent.cs
index 9056b2a..b8a7ea8 100644
--- a/Source/Data/RimMindMemoryWorldComponent.cs
+++ b/Source/Data/RimMindMemoryWorldComponent.cs
@@ -71,6 +71,7 @@ public void ClearPawnStore(Pawn pawn)
{
_pawnStores.Remove(pawn.thingIDNumber);
}
+ RimMind.Memory.Injection.MemoryChangedEvent.Publish(pawn.thingIDNumber);
}
private void SaveAllEntriesToStorage()
@@ -127,17 +128,20 @@ private void MergeFromSnapshot(string? json)
settings.narratorMaxActive,
settings.narratorMaxArchive));
}
+ RimMind.Memory.Injection.MemoryChangedEvent.Publish(0);
}
public void AddPawnMemory(Pawn pawn, MemoryEntry e, int maxActive, int maxArchive)
{
var store = GetOrCreatePawnStore(pawn);
store.AddActive(e, maxActive, maxArchive);
+ RimMind.Memory.Injection.MemoryChangedEvent.Publish(pawn.thingIDNumber);
}
public void AddNarratorMemory(MemoryEntry e, int maxActive, int maxArchive)
{
_narratorStore.AddActive(e, maxActive, maxArchive);
+ RimMind.Memory.Injection.MemoryChangedEvent.Publish(0);
if (RimMindAPI.RemoteSync.IsConfigured)
{
int tick = Find.TickManager.TicksGame;
diff --git a/Source/Injection/MemoryChangedEvent.cs b/Source/Injection/MemoryChangedEvent.cs
new file mode 100644
index 0000000..16a245e
--- /dev/null
+++ b/Source/Injection/MemoryChangedEvent.cs
@@ -0,0 +1,39 @@
+using System;
+using RimMind.Domain.Events;
+using RimMind.Domain.ValueObjects;
+using RimMind.Presentation.Api;
+
+namespace RimMind.Memory.Injection
+{
+ ///
+ /// Published on the Core bus whenever pawn or narrator memories are added, merged or cleared.
+ /// The memory_pawn and memory_narrator context keys name
+ /// as their invalidation trigger, so new memories reach the next prompt immediately.
+ /// is 0 for narrator memories.
+ ///
+ public sealed class MemoryChangedEvent : AgentBusEvent
+ {
+ /// Bus event name used by context-key invalidation triggers.
+ public const string EventName = "MemoryEvent";
+
+ public MemoryChangedEvent(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-Memory] Event type registration failed: {ex.Message}"); }
+ }
+
+ /// Publishes from any thread; never throws.
+ internal static void Publish(int pawnId)
+ {
+ try { RimMindAPI.Bus.PublishOnMainThread(new MemoryChangedEvent(pawnId)); }
+ catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Memory] Memory change notification failed: {ex.Message}"); }
+ }
+ }
+}
diff --git a/Source/Injection/MemoryContextProvider.cs b/Source/Injection/MemoryContextProvider.cs
index c83ca66..db2004f 100644
--- a/Source/Injection/MemoryContextProvider.cs
+++ b/Source/Injection/MemoryContextProvider.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.ValueObjects;
using RimMind.Presentation.Api;
using RimMind.Memory.Core;
@@ -20,29 +22,42 @@ public static class MemoryContextProvider
private const int PublicProviderPriority = 100;
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);
private static string TrOrFallback(string key, string fallback)
- {
- try
- {
- if (!string.IsNullOrEmpty(key) && key.CanTranslate())
- return key.TranslateSimple();
- }
- catch { }
- return fallback;
- }
+ => RimMindText.TrOr(key, fallback);
public static void Register()
{
+ // 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).
+ MemoryChangedEvent.Register();
+
+ ScenarioRegistry.Register(new ScenarioDescriptor
+ {
+ Id = ScenarioIds.Memory,
+ DefaultBaseRounds = 0,
+ Description = "RimMind.Application.Scenario.Memory",
+ DefaultBudget = 0.4f,
+ L4Mode = L4Mode.None,
+ DefaultExcludeKeys = new[]
+ {
+ "combat_status", "current_job", "mood",
+ "task_progress", "npc_commands", "target_info"
+ },
+ OwnerModId = RimMindOwnerConsts.MemoryModId,
+ RelevanceWeights = new Dictionary(StringComparer.Ordinal)
+ {
+ ["memory_pawn"] = 1.0f,
+ ["memory_narrator"] = 1.0f,
+ ["working_memory"] = 0.9f,
+ ["colony_status"] = 0.6f,
+ ["nearby_pawns"] = 0.6f,
+ ["pawn_base_info"] = 0.7f,
+ ["fixed_relations"] = 0.7f
+ }
+ });
+
RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef(
"memory_pawn", ContextLayer.L3_State, 0.25f,
async (ctx, ct) =>
@@ -122,7 +137,7 @@ public static void Register()
return null;
}
}, null, ct: ct).ConfigureAwait(false);
- }, RimMindOwnerConsts.MemoryModId, stalenessTicks: 1500, invalidationTriggers: new[] { "MemoryEvent" }));
+ }, RimMindOwnerConsts.MemoryModId, stalenessTicks: 1500, invalidationTriggers: new[] { MemoryChangedEvent.EventName }));
RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef(
"memory_narrator", ContextLayer.L4_History, 0.6f,
@@ -192,7 +207,7 @@ public static void Register()
return null;
}
}, null, ct: ct).ConfigureAwait(false);
- }, RimMindOwnerConsts.MemoryModId, stalenessTicks: 3000, invalidationTriggers: new[] { "MemoryEvent" }));
+ }, RimMindOwnerConsts.MemoryModId, stalenessTicks: 3000, invalidationTriggers: new[] { MemoryChangedEvent.EventName }));
RegisterPublicProviders();
}
diff --git a/Source/Injection/WorkingMemoryProvider.cs b/Source/Injection/WorkingMemoryProvider.cs
index 83d6f98..9429799 100644
--- a/Source/Injection/WorkingMemoryProvider.cs
+++ b/Source/Injection/WorkingMemoryProvider.cs
@@ -14,15 +14,7 @@ namespace RimMind.Memory.Injection
public static class WorkingMemoryProvider
{
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);
// Compatibility reader for saved entries, not an active memory-production path.
public static void Register()
@@ -61,7 +53,7 @@ public static void Register()
var result = sb.ToString().TrimEnd();
return string.IsNullOrEmpty(result) ? null : result;
}, null, ct: ct).ConfigureAwait(false);
- }, RimMindOwnerConsts.MemoryModId, stalenessTicks: 750, invalidationTriggers: new[] { "MemoryEvent" }));
+ }, RimMindOwnerConsts.MemoryModId, stalenessTicks: 750)); // legacy save data, never produced at runtime: staleness only
}
}
}
diff --git a/Source/Triggers/Patch_AddHediff.cs b/Source/Triggers/Patch_AddHediff.cs
index 001941e..08bd6e3 100644
--- a/Source/Triggers/Patch_AddHediff.cs
+++ b/Source/Triggers/Patch_AddHediff.cs
@@ -1,6 +1,7 @@
-using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
+using RimMind.Application.Common.Helpers;
+using RimMind.Application.Features.GameEvents;
using RimMind.Domain.ValueObjects;
using RimMind.Memory.Data;
using RimMind.Presentation.Api;
@@ -17,8 +18,7 @@ public static class Patch_AddHediff
public const int InjuryCoalesceWindowTicks = 2500;
private static readonly FieldInfo? PawnField = AccessTools.Field(typeof(Pawn_HealthTracker), "pawn");
- private static readonly object CooldownLock = new object();
- private static readonly Dictionary LastInjuryTickByPawn = new Dictionary();
+ private static readonly KeyedCooldownGate LastInjuryTickByPawn = new KeyedCooldownGate(256);
public static bool ShouldRecordHediff(
bool isBad,
@@ -30,8 +30,16 @@ public static bool ShouldRecordHediff(
int? lastRecordedTick,
int coalesceWindowTicks = InjuryCoalesceWindowTicks)
{
- if (!isBad && !isTendable && !makesSickThought) return false;
- if (!isLifeThreatening && severity < MinHediffSeverity) return false;
+ if (!GameEventSignificancePolicy.IsSignificantInjury(
+ isBad,
+ isTendable,
+ makesSickThought,
+ isLifeThreatening,
+ severity,
+ MinHediffSeverity))
+ {
+ return false;
+ }
if (!isLifeThreatening
&& lastRecordedTick.HasValue
&& nowTick >= lastRecordedTick.Value
@@ -67,35 +75,20 @@ static void Postfix(Pawn_HealthTracker __instance, Hediff hediff, DamageInfo? di
try
{
int now = Find.TickManager?.TicksGame ?? 0;
- lock (CooldownLock)
- {
- int? lastTick = LastInjuryTickByPawn.TryGetValue(pawn.thingIDNumber, out int recorded) ? recorded : (int?)null;
- if (!ShouldRecordHediff(
- hediff.def.isBad,
- hediff.def.tendable,
- hediff.def.makesSickThought,
- hediff.Severity,
- isLifeThreatening,
- now,
- lastTick))
- {
- return;
- }
-
- if (LastInjuryTickByPawn.Count > 128)
- {
- var stale = new List();
- foreach (var kv in LastInjuryTickByPawn)
- {
- if (now < kv.Value || (now - kv.Value) >= InjuryCoalesceWindowTicks * 4)
- stale.Add(kv.Key);
- }
- for (int i = 0; i < stale.Count; i++)
- LastInjuryTickByPawn.Remove(stale[i]);
- }
-
- LastInjuryTickByPawn[pawn.thingIDNumber] = now;
- }
+ int cooldownTicks = isLifeThreatening ? 0 : InjuryCoalesceWindowTicks;
+ if (!LastInjuryTickByPawn.TryEnter(pawn.thingIDNumber, now, cooldownTicks))
+ return;
+
+ RimMindAPI.GameEvents.Publish(new HediffAddedGameEvent(
+ pawn.thingIDNumber,
+ hediff.def.defName ?? string.Empty,
+ hediff.Label ?? hediff.def.label ?? hediff.def.defName ?? string.Empty,
+ hediff.Severity,
+ hediff.def.isBad,
+ hediff.def.tendable,
+ hediff.def.makesSickThought,
+ isLifeThreatening,
+ hediff.Part?.Label));
float importance = EstimateImportance(hediff);
string content = BuildContent(pawn, hediff, dinfo);
diff --git a/Source/Triggers/Patch_SkillLevelUp.cs b/Source/Triggers/Patch_SkillLevelUp.cs
index 565437c..97de37e 100644
--- a/Source/Triggers/Patch_SkillLevelUp.cs
+++ b/Source/Triggers/Patch_SkillLevelUp.cs
@@ -1,10 +1,9 @@
using HarmonyLib;
+using RimMind.Application.Features.GameEvents;
using RimMind.Domain.ValueObjects;
using RimMind.Memory.Data;
using RimMind.Presentation.Api;
using RimWorld;
-using System.Collections.Generic;
-using System.Reflection;
using Verse;
namespace RimMind.Memory.Triggers
@@ -12,21 +11,26 @@ namespace RimMind.Memory.Triggers
[HarmonyPatch(typeof(SkillRecord), nameof(SkillRecord.Learn))]
public static class Patch_SkillLevelUp
{
- private static readonly FieldInfo? PawnField = AccessTools.Field(typeof(SkillRecord), "pawn");
-
- static void Prefix(SkillRecord __instance, out int __state) => __state = __instance?.Level ?? 0;
+ static void Prefix(SkillRecord __instance, float xp, out int __state)
+ => __state = __instance != null && xp > 0f && !__instance.TotallyDisabled ? __instance.Level : -1;
static void Postfix(SkillRecord __instance, int __state)
{
int prevLevel = __state;
- if (__instance == null || __instance.Level <= prevLevel) return;
+ if (prevLevel < 0 || __instance == null || __instance.Level <= prevLevel) return;
if (!MemoryTriggerHelper.ShouldProcess(RimMindMemoryMod.Settings.triggerSkillLevelUp)) return;
try
{
- var pawn = PawnField?.GetValue(__instance) as Pawn;
+ var pawn = __instance.Pawn;
if (pawn == null || !RimMindPawnLookup.IsActiveSpawnedColonist(pawn) || pawn.Name == null) return;
+ RimMindAPI.GameEvents.Publish(new SkillLevelUpGameEvent(
+ pawn.thingIDNumber,
+ __instance.def?.defName ?? string.Empty,
+ prevLevel,
+ __instance.Level));
+
float importance = __instance.Level >= 15 ? 0.7f : 0.5f;
string skillLabel = (__instance.def?.LabelCap.RawText.NullOrEmpty() ?? true)
? (__instance.def?.defName ?? "RimMind.Memory.Trigger.Skill".Translate())
diff --git a/Tests/RimMindMemory.Tests.csproj b/Tests/RimMindMemory.Tests.csproj
index aab4342..8112d29 100644
--- a/Tests/RimMindMemory.Tests.csproj
+++ b/Tests/RimMindMemory.Tests.csproj
@@ -36,10 +36,12 @@
+
+
diff --git a/Tests/VerseStubs.cs b/Tests/VerseStubs.cs
index 0d3d6c9..4ba1f8a 100644
--- a/Tests/VerseStubs.cs
+++ b/Tests/VerseStubs.cs
@@ -55,6 +55,11 @@ public static class Scribe_Deep
public enum LookMode { Value, Deep }
+ public static class UnityData
+ {
+ public static bool IsInMainThread = true;
+ }
+
public static class Log
{
public static void Warning(string msg) { }