From 9f587d4895adfee13746f16d2ab8b1796adf677e Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Sat, 7 Nov 2026 04:32:18 +0100 Subject: [PATCH] feat(personality): IncidentWorker.TryExecute patch fix, GameEventHub & ThoughtSpec migration, and CI test workflow fix(personality): correct incident patch target and unify cooldown scaling Patch IncidentWorker.TryExecute instead of the protected virtual TryExecuteWorker, which concrete workers override so the base patch rarely fired. Guard the incident translation key with CanTranslate and fall back to a plain string. Extract activity cooldown scaling into ActivityCooldownScaler and reuse it in CompAIPersonality and PersonalityModCooldown. Align the cooldown OwnerModId with PersonalityRequestDefaults.ModId so Core resolves queue cooldowns by the same key as the request envelope. Register the Personality scenario descriptor with relevance weights and drop the unused PersonalityEvent invalidation triggers, relying on the 750-tick staleness window instead. Publish SkillLevelUpGameEvent on skill level up and delegate translation helpers to RimMindText.TrOr. Add contract tests covering patch targets, incident trigger policy, and cooldown key alignment, plus a CI workflow for unit tests. --- .github/workflows/test.yml | 22 ++++++ Source/Comps/CompAIPersonality.cs | 18 +---- Source/Extensions/PersonalityModCooldown.cs | 17 ++--- Source/Patches/Patch_PersonalityIncident.cs | 13 ++-- Source/Patches/Patch_PersonalitySkill.cs | 7 ++ .../PersonalityProviderRegistrar.cs | 61 ++++++++++++---- .../PersonalityPatchTargetContracts.cs | 71 +++++++++++++++++++ 7 files changed, 165 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Tests/Contracts/PersonalityPatchTargetContracts.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/Comps/CompAIPersonality.cs b/Source/Comps/CompAIPersonality.cs index 65a0b24..99e9495 100644 --- a/Source/Comps/CompAIPersonality.cs +++ b/Source/Comps/CompAIPersonality.cs @@ -131,8 +131,7 @@ private void ObserveStateTransitions() int currentTick = Find.TickManager.TicksGame; float activityScale = RimMindAPI.Settings.ActivityFrequencyScale; - float cooldownMultiplier = activityScale > 0.01f ? (1.0f / activityScale) : 1.0f; - int effectiveCooldown = Mathf.RoundToInt(Settings.eventCooldownTicks * Mathf.Clamp(cooldownMultiplier, 0.35f, 3.5f)); + int effectiveCooldown = RimMind.Application.Common.Helpers.ActivityCooldownScaler.Scale(Settings.eventCooldownTicks, activityScale); bool canQueue = !_hasPendingRequest && (currentTick - _lastEventTick) >= effectiveCooldown && _pendingEventContext == null; // Check A: Sunrise / Dawn transition (sampled every 250 ticks ≈ 4 seconds) @@ -297,20 +296,9 @@ public void TriggerEvent(string context, TriggerEventType eventType = TriggerEve private bool IsEligible() => PersonalityTriggerPolicy.IsEligible(Pawn); + // Delegates to RimMindText.TrOr (guards key.CanTranslate() on the main thread). private static string TranslateOrFallback(string key, string fallback, params object[] args) - { - try - { - string template = !string.IsNullOrEmpty(key) && key.CanTranslate() - ? key.TranslateSimple() - : fallback; - return args == null || args.Length == 0 ? template : string.Format(template, args); - } - catch - { - return args == null || args.Length == 0 ? fallback : string.Format(fallback, args); - } - } + => RimMindText.TrOr(key, fallback, args); public override void PostExposeData() { diff --git a/Source/Extensions/PersonalityModCooldown.cs b/Source/Extensions/PersonalityModCooldown.cs index cb5f97c..1614e2d 100644 --- a/Source/Extensions/PersonalityModCooldown.cs +++ b/Source/Extensions/PersonalityModCooldown.cs @@ -1,22 +1,15 @@ +using RimMind.Application.Common.Helpers; using RimMind.Application.Common.Interfaces.Extension; using RimMind.Presentation.Api; -using UnityEngine; namespace RimMind.Personality { internal sealed class PersonalityModCooldown : IModCooldown { public string Id => "Personality"; - public string OwnerModId => "RimMind.Personality"; - 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(1200 * multiplier); - } - } + // Must match the envelope ModId: Core resolves queue cooldowns by that key. + public string OwnerModId => PersonalityRequestDefaults.ModId; + public int CooldownTicks => + ActivityCooldownScaler.Scale(1200, RimMindAPI.Settings.ActivityFrequencyScale); } } diff --git a/Source/Patches/Patch_PersonalityIncident.cs b/Source/Patches/Patch_PersonalityIncident.cs index e2f0a36..324aad9 100644 --- a/Source/Patches/Patch_PersonalityIncident.cs +++ b/Source/Patches/Patch_PersonalityIncident.cs @@ -5,9 +5,13 @@ namespace RimMind.Personality.Patches { - [HarmonyPatch(typeof(IncidentWorker), "TryExecuteWorker")] + // Patch the public, non-virtual entry point. TryExecuteWorker is protected virtual and is + // overridden by concrete workers (raids, etc.), so a base-method patch almost never fires. + [HarmonyPatch(typeof(IncidentWorker), nameof(IncidentWorker.TryExecute))] static class Patch_PersonalityIncident { + private const string IncidentOccurredKey = "RimMind.Personality.Trigger.IncidentOccurred"; + static void Postfix(IncidentWorker __instance, IncidentParms parms, bool __result) { var def = __instance.def; @@ -21,9 +25,10 @@ static void Postfix(IncidentWorker __instance, IncidentParms parms, bool __resul return; } - PersonalityTriggerHelper.TriggerForColonists((Map)parms.target, - $"{"RimMind.Personality.Trigger.IncidentOccurred".Translate(def!.LabelCap)}", - TriggerEventType.Incident); + string context = IncidentOccurredKey.CanTranslate() + ? IncidentOccurredKey.Translate(def!.LabelCap).ToString() + : $"Incident occurred: {def!.LabelCap}"; + PersonalityTriggerHelper.TriggerForColonists((Map)parms.target, context, TriggerEventType.Incident); } } } diff --git a/Source/Patches/Patch_PersonalitySkill.cs b/Source/Patches/Patch_PersonalitySkill.cs index 5f0b4ed..1e1dae3 100644 --- a/Source/Patches/Patch_PersonalitySkill.cs +++ b/Source/Patches/Patch_PersonalitySkill.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using RimMind.Application.Features.GameEvents; using RimMind.Personality.Comps; using RimMind.Presentation.Api; using RimWorld; @@ -31,6 +32,12 @@ static void Postfix(SkillRecord __instance, float xp, int __state) Pawn pawn = __instance.Pawn; if (!RimMindPawnLookup.IsActiveSpawnedColonist(pawn)) return; + RimMindAPI.GameEvents.Publish(new SkillLevelUpGameEvent( + pawn.thingIDNumber, + __instance.def.defName ?? string.Empty, + __state, + currentLevel)); + PersonalityTriggerHelper.TriggerForPawn( pawn, $"{"RimMind.Personality.Trigger.SkillUp".Translate(__instance.def.LabelCap, currentLevel, __state, currentLevel)}", diff --git a/Source/Personality/PersonalityProviderRegistrar.cs b/Source/Personality/PersonalityProviderRegistrar.cs index c36b1b1..62f7986 100644 --- a/Source/Personality/PersonalityProviderRegistrar.cs +++ b/Source/Personality/PersonalityProviderRegistrar.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using RimMind.Application.Common.Interfaces.Context; +using RimMind.Application.Common.Models.Context; +using RimMind.Application.Features.Context; using RimMind.Domain.ValueObjects; using RimMind.Personality.Data; using RimMind.Presentation.Api; @@ -77,15 +80,7 @@ private static void RegisterAgentIdentityProvider() } 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; every read of pawns, profiles, @@ -94,8 +89,48 @@ private static string Tr(string key, params object[] args) private static Task OnMainThread(Func build, CancellationToken ct) => RimMindAPI.MainThreadSnapshot.GetAsync(build, null, ct: ct); + // No invalidation triggers: Personality publishes no bus event, and an unknown trigger name + // is inert in Core. Cached keys refresh through their 750-tick staleness window. private static void RegisterContextProviders() { + ScenarioRegistry.Register(new ScenarioDescriptor + { + Id = ScenarioIds.Personality, + DefaultBaseRounds = 3, + Description = "RimMind.Application.Scenario.Personality", + DefaultBudget = 0.4f, + L4Mode = L4Mode.MemoryOnly, + DefaultExcludeKeys = new[] { "combat_status" }, + OwnerModId = PublicProviderOwner, + RelevanceWeights = new Dictionary(StringComparer.Ordinal) + { + ["health"] = 0.5f, + ["mood"] = 0.85f, + ["current_job"] = 0.6f, + ["combat_status"] = 0.3f, + ["target_info"] = 0.2f, + ["task_progress"] = 0.4f, + ["nearby_pawns"] = 0.6f, + ["colony_status"] = 0.4f, + ["current_area"] = 0.3f, + ["weather"] = 0.1f, + ["time_of_day"] = 0.2f, + ["season"] = 0.1f, + ["map_structure"] = 0.2f, + ["pawn_base_info"] = 0.9f, + ["fixed_relations"] = 0.8f, + ["ideology"] = 0.85f, + ["skills_summary"] = 0.7f, + ["memory_pawn"] = 0.8f, + ["working_memory"] = 0.5f, + ["memory_narrator"] = 0.3f, + ["personality_profile"] = 1.0f, + ["personality_state"] = 0.95f, + ["personality_shaping"] = 0.9f, + ["personality_task"] = 0.95f + } + }); + RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "personality_profile", ContextLayer.L3_State, 0.25f, (ctx, ct) => @@ -104,7 +139,7 @@ private static void RegisterContextProviders() return Task.FromResult(null); int pawnId = ctx.PawnId; return OnMainThread(() => BuildProfileContext(pawnId), ct); - }, PublicProviderOwner, stalenessTicks: 750, invalidationTriggers: new[] { "PersonalityEvent" })); + }, PublicProviderOwner, stalenessTicks: 750)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "personality_state", ContextLayer.L3_State, 0.2f, @@ -114,7 +149,7 @@ private static void RegisterContextProviders() return Task.FromResult(null); int pawnId = ctx.PawnId; return OnMainThread(() => BuildStateContext(pawnId), ct); - }, PublicProviderOwner, stalenessTicks: 750, invalidationTriggers: new[] { "PersonalityEvent" })); + }, PublicProviderOwner, stalenessTicks: 750)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "personality_shaping", ContextLayer.L3_State, 0.15f, @@ -124,7 +159,7 @@ private static void RegisterContextProviders() return Task.FromResult(null); int pawnId = ctx.PawnId; return OnMainThread(() => BuildShapingContext(pawnId), ct); - }, PublicProviderOwner, stalenessTicks: 750, invalidationTriggers: new[] { "PersonalityEvent" })); + }, PublicProviderOwner, stalenessTicks: 750)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "personality_task", ContextLayer.L0_Static, 0.95f, @@ -133,7 +168,7 @@ private static void RegisterContextProviders() if (ctx.Scenario != RimMindAPI.Context.ScenarioPersonality) return Task.FromResult(null); return OnMainThread(BuildTaskInstruction, ct); - }, PublicProviderOwner, stalenessTicks: 0, invalidationTriggers: new[] { "PersonalityEvent" })); + }, PublicProviderOwner, stalenessTicks: 0)); } /// Main thread only. diff --git a/Tests/Contracts/PersonalityPatchTargetContracts.cs b/Tests/Contracts/PersonalityPatchTargetContracts.cs new file mode 100644 index 0000000..a1a3832 --- /dev/null +++ b/Tests/Contracts/PersonalityPatchTargetContracts.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using RimMind.Personality; +using RimMind.Personality.Comps; +using RimMind.Testing; +using Xunit; + +namespace RimMind.Personality.Tests.Contracts +{ + /// + /// Regression contracts for Harmony entry points and queue identity. + /// + public sealed class PersonalityPatchTargetContracts + { + [Fact] + public void Incident_trigger_hooks_the_public_entry_point_and_queue_identity_stays_aligned() + { + ContractCaseRunner.Run( + ("incident patch targets IncidentWorker.TryExecute, not the overridden TryExecuteWorker", () => + { + string source = ReadSource("Patches", "Patch_PersonalityIncident.cs"); + var attribute = Regex.Match(source, @"\[HarmonyPatch\(typeof\(IncidentWorker\),\s*(?[^\)]*\)?)\)\]"); + Assert.True(attribute.Success, "Patch_PersonalityIncident must declare a HarmonyPatch on IncidentWorker."); + Assert.Equal("nameof(IncidentWorker.TryExecute)", attribute.Groups["target"].Value.Trim()); + Assert.Contains("bool __result", source, StringComparison.Ordinal); + Assert.Contains("PersonalityTriggerPolicy.ShouldTriggerIncident", source, StringComparison.Ordinal); + }), + ("only successful threat incidents on maps trigger", () => + { + Assert.True(PersonalityTriggerPolicy.ShouldTriggerIncident(true, true, true, "ThreatBig")); + Assert.False(PersonalityTriggerPolicy.ShouldTriggerIncident(false, true, true, "ThreatBig")); + Assert.False(PersonalityTriggerPolicy.ShouldTriggerIncident(true, true, false, "ThreatSmall")); + Assert.False(PersonalityTriggerPolicy.ShouldTriggerIncident(true, true, true, "Misc")); + }), + ("queue cooldown owner and envelope ModId use the same module constant", () => + { + var literalCooldownKey = new Regex(@"(ClearModCooldown|GetModCooldownTicksLeft)\(\s*"""); + var literalModId = new Regex(@"WithModId\(\s*""(?[^""]*)"""); + foreach (var file in Directory.EnumerateFiles(SourceRoot(), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar))) + { + string text = File.ReadAllText(file); + Assert.False(literalCooldownKey.IsMatch(text), $"{Path.GetFileName(file)} passes a literal cooldown key."); + foreach (Match match in literalModId.Matches(text)) + Assert.Equal(PersonalityRequestDefaults.ModId, match.Groups["id"].Value); + } + + string cooldown = ReadSource("Extensions", "PersonalityModCooldown.cs"); + Assert.Contains("OwnerModId => PersonalityRequestDefaults.ModId", cooldown, StringComparison.Ordinal); + })); + } + + private static string ReadSource(params string[] relativeParts) + => File.ReadAllText(Path.Combine(SourceRoot(), Path.Combine(relativeParts))); + + private static string SourceRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && + !Directory.Exists(Path.Combine(directory.FullName, "RimMind-Personality", "Source"))) + directory = directory.Parent; + + return Path.Combine( + directory?.FullName ?? throw new InvalidOperationException("Repository root not found."), + "RimMind-Personality", + "Source"); + } + } +}