From bc53a89cef8d831e85df422286f00c3e32dc45f1 Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Sat, 7 Nov 2026 04:32:28 +0100 Subject: [PATCH] feat(storyteller): request queue identity unification, envelope hints, and CI test workflow fix(storyteller): align queue cooldown key with envelope mod id The forced-request path cleared the "Storyteller" cooldown while Core stores per-mod cooldowns under the envelope ModId ("RimMind.Storyteller"), so the clear silently targeted a key that never existed. Introduce StorytellerRequestIds.RequestModId as the single source of truth and use it for the envelope, cooldown clear, and StorytellerModCooldown. Also register a typed StorytellerStateChangedEvent on the bus before any context key names it, since unknown trigger names never fall back to the base bus event, and publish it after dialogue turns so cached context reaches the next prompt. Register the Storyteller scenario descriptor, use JsonRepairer for truncated responses, and add a contract test plus CI workflow to guard the queue identity. --- .github/workflows/test.yml | 22 ++++++ Source/Extensions/StorytellerModCooldown.cs | 5 +- .../Patch/Patch_IncidentWorker_TryExecute.cs | 2 +- .../StorytellerContextProviderRegistrar.cs | 72 +++++++++++++++---- .../StorytellerRequestCoordinator.cs | 15 ++-- Source/Storyteller/StorytellerRequestIds.cs | 38 ++++++++++ .../StorytellerResponseParserPure.cs | 3 +- .../StorytellerStateChangedEvent.cs | 32 +++++++++ Source/UI/Window_StorytellerDialogue.cs | 5 +- .../StorytellerQueueIdentityContracts.cs | 61 ++++++++++++++++ Tests/RimMindStoryteller.Tests.csproj | 3 + Tests/VerseStubs.cs | 7 ++ 12 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Source/Storyteller/StorytellerRequestIds.cs create mode 100644 Source/Storyteller/StorytellerStateChangedEvent.cs create mode 100644 Tests/Contracts/StorytellerQueueIdentityContracts.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/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