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/Extensions/StorytellerModCooldown.cs b/Source/Extensions/StorytellerModCooldown.cs index 237eef5..7388fef 100644 --- a/Source/Extensions/StorytellerModCooldown.cs +++ b/Source/Extensions/StorytellerModCooldown.cs @@ -7,8 +7,9 @@ internal sealed class StorytellerModCooldown : IModCooldown { private readonly RimMindStorytellerSettings _settings; public StorytellerModCooldown(RimMindStorytellerSettings settings) { _settings = settings; } - public string Id => "Storyteller"; - public string OwnerModId => "RimMind.Storyteller"; + // Must equal the envelope ModId: Core resolves and stores queue cooldowns by that key. + public string Id => StorytellerRequestIds.RequestModId; + public string OwnerModId => StorytellerRequestIds.RequestModId; public int CooldownTicks => (int)(_settings.mtbDays * 60000f); } } diff --git a/Source/Patch/Patch_IncidentWorker_TryExecute.cs b/Source/Patch/Patch_IncidentWorker_TryExecute.cs index 22fa2cc..974ecf0 100644 --- a/Source/Patch/Patch_IncidentWorker_TryExecute.cs +++ b/Source/Patch/Patch_IncidentWorker_TryExecute.cs @@ -7,7 +7,7 @@ namespace RimMind.Storyteller.Patch { - [HarmonyPatch(typeof(IncidentWorker), "TryExecute")] + [HarmonyPatch(typeof(IncidentWorker), nameof(IncidentWorker.TryExecute))] public static class Patch_IncidentWorker_TryExecute { static void Postfix(IncidentWorker __instance, IncidentParms parms, bool __result) diff --git a/Source/Storyteller/StorytellerContextProviderRegistrar.cs b/Source/Storyteller/StorytellerContextProviderRegistrar.cs index 795181f..6060dc7 100644 --- a/Source/Storyteller/StorytellerContextProviderRegistrar.cs +++ b/Source/Storyteller/StorytellerContextProviderRegistrar.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; using System.Text; 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.Storyteller.Extensions; @@ -13,18 +17,58 @@ internal static class StorytellerContextProviderRegistrar private const string ModId = RimMind.Application.Common.Constants.RimMindOwnerConsts.StorytellerModId; 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); internal static void RegisterAll() { + // 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). + StorytellerStateChangedEvent.Register(); + + ScenarioRegistry.Register(new ScenarioDescriptor + { + Id = ScenarioIds.Storyteller, + DefaultBaseRounds = 8, + Description = "RimMind.Application.Scenario.Storyteller", + DefaultBudget = 0.7f, + L4Mode = L4Mode.NarrativeMemory, + DefaultExcludeKeys = new[] + { + "npc_identity", "pawn_base_info", "fixed_relations", + "ideology", "skills_summary", "health", "mood", "current_job", + "combat_status", "target_info", "task_progress", "npc_commands" + }, + OwnerModId = ModId, + RelevanceWeights = new Dictionary(StringComparer.Ordinal) + { + ["health"] = 0.3f, + ["mood"] = 0.4f, + ["current_job"] = 0.2f, + ["combat_status"] = 0.8f, + ["target_info"] = 0.7f, + ["task_progress"] = 0.3f, + ["nearby_pawns"] = 0.5f, + ["colony_status"] = 0.9f, + ["current_area"] = 0.4f, + ["weather"] = 0.6f, + ["time_of_day"] = 0.5f, + ["season"] = 0.7f, + ["map_structure"] = 0.8f, + ["pawn_base_info"] = 0.3f, + ["fixed_relations"] = 0.3f, + ["ideology"] = 0.4f, + ["skills_summary"] = 0.2f, + ["memory_pawn"] = 0.3f, + ["working_memory"] = 0.2f, + ["memory_narrator"] = 0.9f, + ["storyteller_dialogue"] = 0.6f, + ["storyteller_task"] = 0.95f, + ["storyteller_context"] = 0.95f, + ["storyteller_reactions"] = 0.9f, + ["storyteller_recent_incidents"] = 0.85f + } + }); + RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( "storyteller_dialogue", ContextLayer.L3_State, 0.5f, async (ctx, ct) => @@ -42,7 +86,7 @@ internal static void RegisterAll() ? null : $"{Tr("RimMind.Storyteller.Dialogue.StorytellerDialogueHeader")}\n{dialogue}"; }, null, ct: ct).ConfigureAwait(false); - }, ownerMod: ModId, stalenessTicks: 750, invalidationTriggers: new[] { "StorytellerEvent" }, + }, ownerMod: ModId, stalenessTicks: 750, invalidationTriggers: new[] { StorytellerStateChangedEvent.EventName }, cacheScope: CacheScope.Scenario)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( @@ -74,7 +118,7 @@ internal static void RegisterAll() StorytellerMemory.Instance?.CustomSystemPrompt, taskInstruction); }, null, ct: ct).ConfigureAwait(false); - }, ownerMod: ModId, stalenessTicks: 0, invalidationTriggers: new[] { "StorytellerEvent" }, + }, ownerMod: ModId, stalenessTicks: 0, cacheScope: CacheScope.Static)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( @@ -104,7 +148,7 @@ internal static void RegisterAll() sb.AppendLine(chains); return sb.ToString().TrimEnd(); }, null, ct: ct).ConfigureAwait(false); - }, ownerMod: ModId, stalenessTicks: 3000, invalidationTriggers: new[] { "StorytellerEvent" }, + }, ownerMod: ModId, stalenessTicks: 3000, invalidationTriggers: new[] { StorytellerStateChangedEvent.EventName }, cacheScope: CacheScope.Scenario)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( @@ -122,7 +166,7 @@ internal static void RegisterAll() string? text = mem.ConsumedReactionsText; return !string.IsNullOrEmpty(text) ? text : null; }, null, ct: ct).ConfigureAwait(false); - }, ownerMod: ModId, stalenessTicks: 3000, invalidationTriggers: new[] { "StorytellerEvent" }, + }, ownerMod: ModId, stalenessTicks: 3000, invalidationTriggers: new[] { StorytellerStateChangedEvent.EventName }, cacheScope: CacheScope.Scenario)); RimMindAPI.Context.ContextKeys.Register(new ContextProviderDef( @@ -147,7 +191,7 @@ internal static void RegisterAll() } return narrationText.ToString().TrimEnd(); }, null, ct: ct).ConfigureAwait(false); - }, ownerMod: ModId, stalenessTicks: 3000, invalidationTriggers: new[] { "StorytellerEvent" }, + }, ownerMod: ModId, stalenessTicks: 3000, invalidationTriggers: new[] { StorytellerStateChangedEvent.EventName }, cacheScope: CacheScope.Scenario)); } diff --git a/Source/Storyteller/StorytellerRequestCoordinator.cs b/Source/Storyteller/StorytellerRequestCoordinator.cs index 7b5fcf4..1552336 100644 --- a/Source/Storyteller/StorytellerRequestCoordinator.cs +++ b/Source/Storyteller/StorytellerRequestCoordinator.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using RimMind.Application.Features.Llm; using RimMind.Domain.Llm; using RimMind.Domain.ValueObjects; using RimMind.Presentation.Api; @@ -98,7 +97,7 @@ private bool Dispatch(IIncidentTarget target, bool forced) _memory.PeekReactions(20); if (forced) { - RimMindAPI.ClearModCooldown("Storyteller"); + RimMindAPI.ClearModCooldown(StorytellerRequestIds.RequestModId); Log.Message( "[RimMind-Storyteller] ForceRequest: sending structured AI request"); } @@ -124,14 +123,10 @@ private void SendRequest( IIncidentTarget target, long requestToken) { - var envelope = LlmRequestEnvelopeBuilder - .ForScenario(RimMindAPI.Context.ScenarioStoryteller) - .WithModId("RimMind.Storyteller") - .WithSchema(RimMindAPI.Context.SchemaIncidentOutput) - .WithMaxTokens(400) - .WithTemperature(0.8f) - .WithNpcId(npcId) - .Build(); + var envelope = StorytellerIncidentRequestFactory.Create( + RimMindAPI.Context.ScenarioStoryteller, + RimMindAPI.Context.SchemaIncidentOutput, + npcId); RimMindAPI.Request.Send( envelope, diff --git a/Source/Storyteller/StorytellerRequestIds.cs b/Source/Storyteller/StorytellerRequestIds.cs new file mode 100644 index 0000000..837023e --- /dev/null +++ b/Source/Storyteller/StorytellerRequestIds.cs @@ -0,0 +1,38 @@ +using RimMind.Application.Features.Llm; +using RimMind.Domain.Llm; + +namespace RimMind.Storyteller +{ + /// + /// Storyteller-owned identity used by Core's request queue. + /// The same id must be passed to LlmRequestEnvelopeBuilder.WithModId, to + /// RimMindAPI.ClearModCooldown / GetModCooldownTicksLeft, and exposed by + /// StorytellerModCooldown: Core stores per-mod cooldowns under the envelope ModId, + /// so any other spelling silently targets a cooldown key that never exists. + /// + public static class StorytellerRequestIds + { + /// Envelope ModId and Core queue cooldown key for every Storyteller request. + public const string RequestModId = "RimMind.Storyteller"; + } + + /// + /// Builds the incident-selection envelope. Kept free of RimWorld state so the queue identity + /// can be verified outside the game. + /// + internal static class StorytellerIncidentRequestFactory + { + internal const int MaxTokens = 400; + internal const float Temperature = 0.8f; + + internal static LlmRequestEnvelope Create(string scenarioId, string? schema, string npcId) + => LlmRequestEnvelopeBuilder + .ForScenario(scenarioId) + .WithModId(StorytellerRequestIds.RequestModId) + .WithSchema(schema) + .WithMaxTokens(MaxTokens) + .WithTemperature(Temperature) + .WithNpcId(npcId) + .Build(); + } +} diff --git a/Source/Storyteller/StorytellerResponseParserPure.cs b/Source/Storyteller/StorytellerResponseParserPure.cs index e7e5621..a8beea2 100644 --- a/Source/Storyteller/StorytellerResponseParserPure.cs +++ b/Source/Storyteller/StorytellerResponseParserPure.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using RimMind.Application.Features.Json; namespace RimMind.Storyteller { @@ -139,7 +140,7 @@ public static string ExtractJsonSubstring(string input) } catch { - return Repair(clean); + return JsonRepairer.TryRepairTruncatedJson(clean) ?? Repair(clean); } } diff --git a/Source/Storyteller/StorytellerStateChangedEvent.cs b/Source/Storyteller/StorytellerStateChangedEvent.cs new file mode 100644 index 0000000..96fe898 --- /dev/null +++ b/Source/Storyteller/StorytellerStateChangedEvent.cs @@ -0,0 +1,32 @@ +using System; +using RimMind.Domain.Events; +using RimMind.Domain.ValueObjects; +using RimMind.Presentation.Api; + +namespace RimMind.Storyteller +{ + /// + /// Published on the Core bus when storyteller state that feeds context changes (player chat + /// turns, incidents, tension). The cached storyteller context keys name + /// as their invalidation trigger. + /// + public sealed class StorytellerStateChangedEvent : AgentBusEvent + { + /// Bus event name used by context-key invalidation triggers. + public const string EventName = "StorytellerEvent"; + + /// 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-Storyteller] Event type registration failed: {ex.Message}"); } + } + + /// Publishes from any thread; never throws. + internal static void Publish() + { + try { RimMindAPI.Bus.PublishOnMainThread(new StorytellerStateChangedEvent()); } + catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Storyteller] State change notification failed: {ex.Message}"); } + } + } +} diff --git a/Source/UI/Window_StorytellerDialogue.cs b/Source/UI/Window_StorytellerDialogue.cs index c652962..cd9ff2b 100644 --- a/Source/UI/Window_StorytellerDialogue.cs +++ b/Source/UI/Window_StorytellerDialogue.cs @@ -201,7 +201,8 @@ private void SendMessage() .AddSection("dialogue_input", userMsg) .AddSection("IsStorytellerChat", "true")) .ForScenarioId(ScenarioIds.Storyteller) - .WithModId("RimMind.Storyteller") + .WithModId(StorytellerRequestIds.RequestModId) + .WithHint("IsStorytellerChat", "true") .WithMaxTokens(400) .WithTemperature(0.9f) .Build(); @@ -253,6 +254,8 @@ private static void RecordDialogueToMemory(string role, string content) int tick = Find.TickManager.TicksGame; memory.RecordDialogue(role, content, tick); + // storyteller_dialogue context is cached; the new turn must reach the next prompt. + StorytellerStateChangedEvent.Publish(); TryPushToMemoryMod(role, content, tick); } diff --git a/Tests/Contracts/StorytellerQueueIdentityContracts.cs b/Tests/Contracts/StorytellerQueueIdentityContracts.cs new file mode 100644 index 0000000..78f01ea --- /dev/null +++ b/Tests/Contracts/StorytellerQueueIdentityContracts.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using RimMind.Testing; +using Xunit; + +namespace RimMind.Storyteller.Tests.Contracts +{ + /// + /// Regression contract for B7: the cooldown key cleared by a forced request must be the + /// envelope ModId Core uses to store the Storyteller queue cooldown. + /// + public sealed class StorytellerQueueIdentityContracts + { + [Fact] + public void Queue_cooldown_key_is_the_envelope_mod_id() + { + ContractCaseRunner.Run( + ("incident request envelope uses the module-owned request id", () => + { + var envelope = StorytellerIncidentRequestFactory.Create("Storyteller", "{}", "NPC-storyteller"); + Assert.Equal(StorytellerRequestIds.RequestModId, envelope.ModId); + Assert.Equal("Storyteller", envelope.ScenarioId); + Assert.Equal("NPC-storyteller", envelope.NpcId); + Assert.Equal(StorytellerIncidentRequestFactory.MaxTokens, envelope.MaxTokens); + }), + ("source never clears or reads a cooldown with a literal key or diverging ModId", () => + { + var literalCooldownKey = new Regex(@"(ClearModCooldown|GetModCooldownTicksLeft)\(\s*"""); + var literalModId = new Regex(@"WithModId\(\s*""(?[^""]*)"""); + string sourceRoot = SourceRoot(); + var files = Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar)) + .ToList(); + Assert.NotEmpty(files); + foreach (var file in files) + { + string text = File.ReadAllText(file); + Assert.False(literalCooldownKey.IsMatch(text), + $"{Path.GetFileName(file)} passes a literal cooldown key; use StorytellerRequestIds.RequestModId."); + foreach (Match match in literalModId.Matches(text)) + Assert.Equal(StorytellerRequestIds.RequestModId, match.Groups["id"].Value); + } + })); + } + + private static string SourceRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && + !Directory.Exists(Path.Combine(directory.FullName, "RimMind-Storyteller", "Source"))) + directory = directory.Parent; + + return Path.Combine( + directory?.FullName ?? throw new InvalidOperationException("Repository root not found."), + "RimMind-Storyteller", + "Source"); + } + } +} diff --git a/Tests/RimMindStoryteller.Tests.csproj b/Tests/RimMindStoryteller.Tests.csproj index ab8d214..3c881b8 100644 --- a/Tests/RimMindStoryteller.Tests.csproj +++ b/Tests/RimMindStoryteller.Tests.csproj @@ -31,12 +31,15 @@ + + + diff --git a/Tests/VerseStubs.cs b/Tests/VerseStubs.cs index 59211dd..6bb277c 100644 --- a/Tests/VerseStubs.cs +++ b/Tests/VerseStubs.cs @@ -11,6 +11,7 @@ public static class Extensions public static bool CanTranslate(this string key) => false; public static string TranslateSimple(this string key) => key; } + public static class UnityData { public static bool IsInMainThread = true; } public static class Log { public static void Message(string message) { } } public class Pawn { public int thingIDNumber; public string ThingID => $"Pawn_{thingIDNumber}"; } public class WorldPawns { public List AllPawnsAlive { get; } = new(); } @@ -172,6 +173,12 @@ public static class Memory public static IReadOnlyList GetRecentNarrations(int count) => Narrations.Take(count).ToList(); } public sealed class Narration { public int Tick; public string Content = ""; } + public static class Bus + { + public static void RegisterEventType(string name) where T : RimMind.Domain.Events.AgentBusEvent + => RimMind.Application.Features.AgentBus.AgentBusImpl.RegisterKnownEventType(name, typeof(T)); + public static void PublishOnMainThread(T evt) where T : RimMind.Domain.Events.AgentBusEvent { } + } } public static class RimMindPawnLookup