Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 3 additions & 15 deletions Source/Comps/CompAIPersonality.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
if (parent == null || !parent.IsHashIntervalTick(60)) return;
if (!Settings.enablePersonality) return;
if (!(parent is Pawn pawn) || !RimMindPawnLookup.IsActiveSpawnedColonist(pawn)) return;
if (RimMindAPI.IsConfigured() == false) return;

Check warning on line 62 in Source/Comps/CompAIPersonality.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.IsConfigured()' is obsolete: 'Use RimMindAPI.Settings.IsConfigured instead.'

Check warning on line 62 in Source/Comps/CompAIPersonality.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.IsConfigured()' is obsolete: 'Use RimMindAPI.Settings.IsConfigured instead.'
if (RimMindAPI.IsAgentActive(GetCachedThingId(pawn))) return;
if (_hasPendingRequest)
{
Expand Down Expand Up @@ -131,8 +131,7 @@

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)
Expand Down Expand Up @@ -297,20 +296,9 @@

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()
{
Expand Down
17 changes: 5 additions & 12 deletions Source/Extensions/PersonalityModCooldown.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 9 additions & 4 deletions Source/Patches/Patch_PersonalityIncident.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
}
7 changes: 7 additions & 0 deletions Source/Patches/Patch_PersonalitySkill.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using HarmonyLib;
using RimMind.Application.Features.GameEvents;
using RimMind.Personality.Comps;
using RimMind.Presentation.Api;
using RimWorld;
Expand Down Expand Up @@ -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)}",
Expand Down
61 changes: 48 additions & 13 deletions Source/Personality/PersonalityProviderRegistrar.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -69,7 +72,7 @@

private static void RegisterAgentIdentityProvider()
{
RimMindAPI.RegisterAgentIdentityProvider(pawn =>

Check warning on line 75 in Source/Personality/PersonalityProviderRegistrar.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.RegisterAgentIdentityProvider(Func<Pawn, AgentIdentity?>)' is obsolete: 'Use RimMindAPI.Ext.RegisterAgentIdentityProvider instead.'

Check warning on line 75 in Source/Personality/PersonalityProviderRegistrar.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.RegisterAgentIdentityProvider(Func<Pawn, AgentIdentity?>)' is obsolete: 'Use RimMindAPI.Ext.RegisterAgentIdentityProvider instead.'
{
var profile = AIPersonalityWorldComponent.Instance?.TryGet(pawn);
return profile?.agentIdentity;
Expand All @@ -77,15 +80,7 @@
}

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);

/// <summary>
/// Context providers run on pipeline thread-pool threads; every read of pawns, profiles,
Expand All @@ -94,8 +89,48 @@
private static Task<string?> OnMainThread(Func<string?> 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<string, float>(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) =>
Expand All @@ -104,7 +139,7 @@
return Task.FromResult<string?>(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,
Expand All @@ -114,7 +149,7 @@
return Task.FromResult<string?>(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,
Expand All @@ -124,7 +159,7 @@
return Task.FromResult<string?>(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,
Expand All @@ -133,7 +168,7 @@
if (ctx.Scenario != RimMindAPI.Context.ScenarioPersonality)
return Task.FromResult<string?>(null);
return OnMainThread(BuildTaskInstruction, ct);
}, PublicProviderOwner, stalenessTicks: 0, invalidationTriggers: new[] { "PersonalityEvent" }));
}, PublicProviderOwner, stalenessTicks: 0));
}

/// <summary>Main thread only.</summary>
Expand Down
71 changes: 71 additions & 0 deletions Tests/Contracts/PersonalityPatchTargetContracts.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Regression contracts for Harmony entry points and queue identity.
/// </summary>
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*(?<target>[^\)]*\)?)\)\]");
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*""(?<id>[^""]*)""");
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");
}
}
}
Loading