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"); + } + } +}