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) { }