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
5 changes: 3 additions & 2 deletions Source/Extensions/StorytellerModCooldown.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion Source/Patch/Patch_IncidentWorker_TryExecute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -27,9 +27,9 @@
if (!isThreat || parms?.quest != null)
return;

RimMindAPI.NotifyIncidentExecuted();

Check warning on line 30 in Source/Patch/Patch_IncidentWorker_TryExecute.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.NotifyIncidentExecuted()' is obsolete: 'Use RimMindAPI.Ext.NotifyIncidentExecuted instead.'

if (RimMindAPI.CanTriggerDialogue)

Check warning on line 32 in Source/Patch/Patch_IncidentWorker_TryExecute.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.CanTriggerDialogue' is obsolete: 'Use RimMindAPI.Ext.CanTriggerDialogue instead.'
{
var map = parms?.target as Map;
if (map != null)
Expand All @@ -50,7 +50,7 @@
string incidentContext = "RimMind.Storyteller.Context.IncidentOccurred".CanTranslate()
? "RimMind.Storyteller.Context.IncidentOccurred".Translate(__instance.def.label).ToString()
: $"Incident occurred: {__instance.def.label}";
RimMindAPI.TriggerDialogue(pawn, incidentContext);

Check warning on line 53 in Source/Patch/Patch_IncidentWorker_TryExecute.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.TriggerDialogue(Pawn, string, Pawn?)' is obsolete: 'Use RimMindAPI.Ext.TriggerDialogue instead.'
}
}
}
Expand Down
72 changes: 58 additions & 14 deletions Source/Storyteller/StorytellerContextProviderRegistrar.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, float>(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) =>
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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));
}

Expand Down
15 changes: 5 additions & 10 deletions Source/Storyteller/StorytellerRequestCoordinator.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -76,7 +75,7 @@
if (target is not Map)
return false;

if (!RimMindAPI.IsConfigured())

Check warning on line 78 in Source/Storyteller/StorytellerRequestCoordinator.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.IsConfigured()' is obsolete: 'Use RimMindAPI.Settings.IsConfigured instead.'
{
RimMindErrors.Warn(
"[RimMind-Storyteller] ForceRequest: API not configured");
Expand All @@ -98,7 +97,7 @@
_memory.PeekReactions(20);
if (forced)
{
RimMindAPI.ClearModCooldown("Storyteller");
RimMindAPI.ClearModCooldown(StorytellerRequestIds.RequestModId);

Check warning on line 100 in Source/Storyteller/StorytellerRequestCoordinator.cs

View workflow job for this annotation

GitHub Actions / test / test

'RimMindAPI.ClearModCooldown(string)' is obsolete: 'Use RimMindAPI.Request.ClearModCooldown instead.'
Log.Message(
"[RimMind-Storyteller] ForceRequest: sending structured AI request");
}
Expand All @@ -124,14 +123,10 @@
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,
Expand Down
38 changes: 38 additions & 0 deletions Source/Storyteller/StorytellerRequestIds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using RimMind.Application.Features.Llm;
using RimMind.Domain.Llm;

namespace RimMind.Storyteller
{
/// <summary>
/// Storyteller-owned identity used by Core's request queue.
/// The same id must be passed to <c>LlmRequestEnvelopeBuilder.WithModId</c>, to
/// <c>RimMindAPI.ClearModCooldown</c> / <c>GetModCooldownTicksLeft</c>, and exposed by
/// <c>StorytellerModCooldown</c>: Core stores per-mod cooldowns under the envelope ModId,
/// so any other spelling silently targets a cooldown key that never exists.
/// </summary>
public static class StorytellerRequestIds
{
/// <summary>Envelope ModId and Core queue cooldown key for every Storyteller request.</summary>
public const string RequestModId = "RimMind.Storyteller";
}

/// <summary>
/// Builds the incident-selection envelope. Kept free of RimWorld state so the queue identity
/// can be verified outside the game.
/// </summary>
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();
}
}
3 changes: 2 additions & 1 deletion Source/Storyteller/StorytellerResponseParserPure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RimMind.Application.Features.Json;

namespace RimMind.Storyteller
{
Expand Down Expand Up @@ -139,7 +140,7 @@ public static string ExtractJsonSubstring(string input)
}
catch
{
return Repair(clean);
return JsonRepairer.TryRepairTruncatedJson(clean) ?? Repair(clean);
}
}

Expand Down
32 changes: 32 additions & 0 deletions Source/Storyteller/StorytellerStateChangedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using RimMind.Domain.Events;
using RimMind.Domain.ValueObjects;
using RimMind.Presentation.Api;

namespace RimMind.Storyteller
{
/// <summary>
/// Published on the Core bus when storyteller state that feeds context changes (player chat
/// turns, incidents, tension). The cached storyteller context keys name <see cref="EventName"/>
/// as their invalidation trigger.
/// </summary>
public sealed class StorytellerStateChangedEvent : AgentBusEvent
{
/// <summary>Bus event name used by context-key invalidation triggers.</summary>
public const string EventName = "StorytellerEvent";

/// <summary>Registers <see cref="EventName"/> with the Core bus. Call before registering context keys.</summary>
internal static void Register()
{
try { RimMindAPI.Bus.RegisterEventType<StorytellerStateChangedEvent>(EventName); }
catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Storyteller] Event type registration failed: {ex.Message}"); }
}

/// <summary>Publishes from any thread; never throws.</summary>
internal static void Publish()
{
try { RimMindAPI.Bus.PublishOnMainThread(new StorytellerStateChangedEvent()); }
catch (Exception ex) { RimMindErrors.Warn($"[RimMind-Storyteller] State change notification failed: {ex.Message}"); }
}
}
}
5 changes: 4 additions & 1 deletion Source/UI/Window_StorytellerDialogue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down
61 changes: 61 additions & 0 deletions Tests/Contracts/StorytellerQueueIdentityContracts.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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*""(?<id>[^""]*)""");
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");
}
}
}
3 changes: 3 additions & 0 deletions Tests/RimMindStoryteller.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@
<Compile Include="../Source/Storyteller/StorytellerResponseParserPure.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Storyteller/IncidentSelectionPolicy.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Storyteller/StorytellerRequestState.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Storyteller/StorytellerRequestIds.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Storyteller/StorytellerStateChangedEvent.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Storyteller/StorytellerContextProviderRegistrar.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Storyteller/RimMindIncidentSelector.cs" LinkBase="Storyteller" />
<Compile Include="../Source/Extensions/StorytellerContextBuilder.cs" LinkBase="Extensions" />
<Compile Include="../Source/Extensions/PawnLookup.cs" LinkBase="Extensions" />
<Compile Include="../Source/Extensions/PawnLookupCore.cs" LinkBase="Extensions" />
<Compile Include="../Source/Extensions/StorytellerContextPolicy.cs" LinkBase="Extensions" />
<Compile Include="../../RimMind-Core/Source/Presentation/Common/RimMindText.cs" Link="Support/RimMindText.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading