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
91 changes: 84 additions & 7 deletions Source/Bridge/ActionGate.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using RimMind.Bridge.RimChat.Cooldown;
using RimMind.Bridge.RimChat.Detection;
Expand All @@ -8,44 +9,120 @@ namespace RimMind.Bridge.RimChat.Bridge
{
public static class ActionGate
{
private static readonly HashSet<string> DiplomacyActions = new HashSet<string>
private static readonly HashSet<string> DiplomacyActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"adjust_faction",
"trigger_incident",
"world.faction",
"diplomacy",
"world.storyteller",
"storyteller.incident",
};

private static readonly HashSet<string> SocialActions = new HashSet<string>
private static readonly HashSet<string> SocialActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"romance_attempt",
"romance_breakup",
"pawn.interaction",
"social_relax",
"give_item",
"actions_social_interact",
"social_interact",
};

private static readonly HashSet<string> RecruitActions = new HashSet<string>
private static readonly HashSet<string> RecruitActions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"recruit_agree",
"recruit",
"actions_recruit",
"pawn.recruit",
"prisoner.recruit",
};

public static bool ShouldSkipAction(string? intentId)
{
if (intentId is null || intentId.Length == 0) return false;
if (string.IsNullOrWhiteSpace(intentId)) return false;
if (!RimChatDetector.IsRimChatActive) return false;

var settings = BridgeRimChatSettings.Get();
if (!settings.enableActionGate) return false;
if (settings.forceRimMindActions) return false;

if (settings.skipDiplomacyActions && DiplomacyActions.Contains(intentId))
if (settings.skipDiplomacyActions && IsDiplomacyAction(intentId))
return true;

if (settings.skipSocialActions && SocialActions.Contains(intentId))
if (settings.skipSocialActions && IsSocialAction(intentId))
return true;

if (settings.skipRecruitAgree && RecruitActions.Contains(intentId))
if (settings.skipRecruitAgree && IsRecruitAction(intentId))
return true;

return false;
}

public static bool IsRomanceAction(string? intentId)
{
if (string.IsNullOrWhiteSpace(intentId)) return false;
string normalized = intentId!.Trim();
if (normalized.StartsWith("romance_", StringComparison.OrdinalIgnoreCase))
return true;
if (MatchesPrefix(normalized, "pawn.interaction")
&& (normalized.IndexOf("romance", StringComparison.OrdinalIgnoreCase) >= 0
|| normalized.EndsWith(".romance_attempt", StringComparison.OrdinalIgnoreCase)
|| normalized.EndsWith(".romance_breakup", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return normalized.IndexOf("romance", StringComparison.OrdinalIgnoreCase) >= 0;
}

public static bool IsSocialAction(string? intentId)
{
if (string.IsNullOrWhiteSpace(intentId)) return false;
string normalized = intentId!.Trim();
if (IsRomanceAction(normalized)) return true;
if (SocialActions.Contains(normalized)) return true;
return MatchesPrefix(normalized, "pawn.interaction")
|| normalized.StartsWith("pawn_interaction", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("social.", StringComparison.OrdinalIgnoreCase);
}

public static bool IsDiplomacyAction(string? intentId)
{
if (string.IsNullOrWhiteSpace(intentId)) return false;
string normalized = intentId!.Trim();
if (DiplomacyActions.Contains(normalized)) return true;
return MatchesPrefix(normalized, "world.faction")
|| normalized.StartsWith("faction.", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("faction_", StringComparison.OrdinalIgnoreCase)
|| MatchesPrefix(normalized, "diplomacy")
|| normalized.IndexOf("diplomacy", StringComparison.OrdinalIgnoreCase) >= 0
|| MatchesPrefix(normalized, "world.storyteller")
|| MatchesPrefix(normalized, "storyteller.incident")
|| normalized.StartsWith("incident.", StringComparison.OrdinalIgnoreCase);
}

public static bool IsRecruitAction(string? intentId)
{
if (string.IsNullOrWhiteSpace(intentId)) return false;
string normalized = intentId!.Trim();
if (RecruitActions.Contains(normalized)) return true;
return MatchesPrefix(normalized, "recruit")
|| normalized.StartsWith("recruit_", StringComparison.OrdinalIgnoreCase)
|| MatchesPrefix(normalized, "pawn.recruit")
|| MatchesPrefix(normalized, "prisoner.recruit")
|| normalized.IndexOf("recruit", StringComparison.OrdinalIgnoreCase) >= 0;
}

private static bool MatchesPrefix(string value, string prefix)
{
if (string.Equals(value, prefix, StringComparison.OrdinalIgnoreCase))
return true;
return value.Length > prefix.Length
&& value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& (value[prefix.Length] == '.' || value[prefix.Length] == ':' || value[prefix.Length] == '_');
}

public static bool ShouldSkipStorytellerIncident()
{
if (!RimChatDetector.IsRimChatActive) return false;
Expand Down
4 changes: 2 additions & 2 deletions Source/Bridge/ContextPullBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ private static void RegisterDiplomacyProvider()
() => BuildDiplomacyContext(),
null,
ct: ct).ConfigureAwait(false);
}, ModId, stalenessTicks: 1500, invalidationTriggers: new[] { "RimChatEvent" }));
}, ModId, stalenessTicks: 1500)); // RimChat state changes are external (no bus event): staleness only
}

private static string? BuildDiplomacyContext()
Expand Down Expand Up @@ -146,7 +146,7 @@ private static void RegisterRpgProvider()
if (pawn == null) return null;
return BuildRpgContext(pawn);
}, null, ct: ct).ConfigureAwait(false);
}, ModId, stalenessTicks: 3000, invalidationTriggers: new[] { "RimChatEvent" }));
}, ModId, stalenessTicks: 3000)); // RimChat state changes are external (no bus event): staleness only
}

internal static Pawn? TryFindPawnById(int pawnId)
Expand Down
7 changes: 4 additions & 3 deletions Source/Bridge/DialogueGate.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using RimMind.Application.Common.Constants;
using RimMind.Bridge.RimChat.Detection;
using RimMind.Bridge.RimChat.Settings;
using Verse;
Expand All @@ -14,9 +15,9 @@ public static bool ShouldSkipDialogue(Pawn? pawn, string? triggerType)

return triggerType switch
{
"Chitchat" => settings.enableChitchatGate,
"Auto" => settings.enableAutoGate,
"PlayerInput" => ShouldSkipPlayerInput(settings),
DialogueTriggerKinds.Chitchat => settings.enableChitchatGate,
DialogueTriggerKinds.Auto => settings.enableAutoGate,
DialogueTriggerKinds.PlayerInput => ShouldSkipPlayerInput(settings),
_ => false
};
}
Expand Down
132 changes: 24 additions & 108 deletions Source/Bridge/RimChatApiShim.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.Reflection;
using HarmonyLib;
using RimMind.Application.Features.Bridges;
using RimMind.Domain.ValueObjects;
using Verse;

namespace RimMind.Bridge.RimChat.Bridge
{
Expand All @@ -13,40 +12,26 @@ public static class RimChatApiShim
private const string DiplomacyManagerTypeName = "RimChat.DiplomacySystem.GameComponent_DiplomacyManager";
private const string RpgArchiveManagerTypeName = "RimChat.Memory.RpgNpcDialogueArchiveManager";

internal static readonly ReflectionAccessor Accessor = new ReflectionAccessor(
typeName => AccessTools.TypeByName(typeName),
msg => RimMindErrors.Warn(msg),
"[RimMind-Bridge-RimChat]");

private static Type? _apiType;
private static Type? _diplomacyManagerType;
private static Type? _rpgArchiveManagerType;
private static bool _resolved;

private static readonly ConcurrentDictionary<(Type Type, string Name, BindingFlags Flags), PropertyInfo?> _propertyCache =
new ConcurrentDictionary<(Type Type, string Name, BindingFlags Flags), PropertyInfo?>();

private static readonly ConcurrentDictionary<(Type Type, string Name, BindingFlags Flags), FieldInfo?> _fieldCache =
new ConcurrentDictionary<(Type Type, string Name, BindingFlags Flags), FieldInfo?>();

private static readonly ConcurrentDictionary<string, byte> _warnedMissingMembers =
new ConcurrentDictionary<string, byte>(StringComparer.Ordinal);

internal static void ConfigureTypesForTesting(
Type? apiType,
Type? diplomacyManagerType,
Type? rpgArchiveManagerType)
{
Accessor.ResetForTesting();
_apiType = apiType;
_diplomacyManagerType = diplomacyManagerType;
_rpgArchiveManagerType = rpgArchiveManagerType;
_resolved = true;
_propertyCache.Clear();
_fieldCache.Clear();
_warnedMissingMembers.Clear();
}

private static void WarnOnce(string key, string message)
{
if (_warnedMissingMembers.TryAdd(key, 1))
{
RimMindErrors.Warn(message);
}
}

private static void EnsureResolved()
Expand All @@ -62,9 +47,9 @@ private static void ResolveTypes()
{
try
{
_apiType = AccessTools.TypeByName(ApiTypeName);
_diplomacyManagerType = AccessTools.TypeByName(DiplomacyManagerTypeName);
_rpgArchiveManagerType = AccessTools.TypeByName(RpgArchiveManagerTypeName);
_apiType = Accessor.GetCachedType(ApiTypeName);
_diplomacyManagerType = Accessor.GetCachedType(DiplomacyManagerTypeName);
_rpgArchiveManagerType = Accessor.GetCachedType(RpgArchiveManagerTypeName);
}
catch (Exception ex)
{
Expand All @@ -87,101 +72,32 @@ public static Type? RpgArchiveManagerType
get { EnsureResolved(); return _rpgArchiveManagerType; }
}

public static PropertyInfo? GetCachedProperty(Type type, string propertyName,
public static PropertyInfo? GetCachedProperty(
Type type,
string propertyName,
BindingFlags flags = BindingFlags.Public | BindingFlags.Static)
{
if (type == null || string.IsNullOrEmpty(propertyName)) return null;
var prop = _propertyCache.GetOrAdd(
(type, propertyName, flags),
static k => k.Type.GetProperty(k.Name, k.Flags));
if (prop == null)
{
WarnOnce(
$"prop:{type.FullName}:{propertyName}:{flags}",
$"[RimMind-Bridge-RimChat] Property '{propertyName}' not found on {type.Name}.");
}
return prop;
}
=> Accessor.GetCachedProperty(type, propertyName, flags);

public static FieldInfo? GetCachedField(Type type, string fieldName,
public static FieldInfo? GetCachedField(
Type type,
string fieldName,
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance)
{
if (type == null || string.IsNullOrEmpty(fieldName)) return null;
var field = _fieldCache.GetOrAdd(
(type, fieldName, flags),
static k => k.Type.GetField(k.Name, k.Flags));
if (field == null)
{
WarnOnce(
$"field:{type.FullName}:{fieldName}:{flags}",
$"[RimMind-Bridge-RimChat] Field '{fieldName}' not found on {type.Name}.");
}
return field;
}
=> Accessor.GetCachedField(type, fieldName, flags);

public static object? GetStaticPropertyValue(Type type, string propertyName)
{
if (type == null)
{
RimMindErrors.Warn("[RimMind-Bridge-RimChat] GetStaticPropertyValue: type is null.");
return null;
}
=> Accessor.GetStaticPropertyValue(type, propertyName);

try
{
var prop = GetCachedProperty(type, propertyName, BindingFlags.Public | BindingFlags.Static);
if (prop == null) return null;
return prop.GetValue(null);
}
catch (Exception ex)
{
RimMindErrors.Warn($"[RimMind-Bridge-RimChat] GetStaticPropertyValue({type.Name}.{propertyName}) failed: {ex.Message}");
return null;
}
}

public static object? GetInstanceFieldValue(object instance, string fieldName,
public static object? GetInstanceFieldValue(
object instance,
string fieldName,
BindingFlags flags = BindingFlags.Public | BindingFlags.Instance)
{
if (instance == null)
{
RimMindErrors.Warn("[RimMind-Bridge-RimChat] GetInstanceFieldValue: instance is null.");
return null;
}

try
{
var field = GetCachedField(instance.GetType(), fieldName, flags);
if (field == null) return null;
return field.GetValue(instance);
}
catch (Exception ex)
{
RimMindErrors.Warn($"[RimMind-Bridge-RimChat] GetInstanceFieldValue({instance.GetType().Name}.{fieldName}) failed: {ex.Message}");
return null;
}
}
=> Accessor.GetInstanceFieldValue(instance, fieldName, flags);

/// <summary>
/// 获取 RimChat manager 的单例实例(反射读取静态 "Instance" 属性)。
/// 统一 BuildDiplomacyContext / BuildRpgContext 的 manager 获取逻辑。
/// </summary>
public static object? TryGetManagerInstance(Type? managerType, string instancePropertyName = "Instance")
{
if (managerType == null)
{
RimMindErrors.Warn($"[RimMind-Bridge-RimChat] TryGetManagerInstance: managerType is null.");
return null;
}

var instance = GetStaticPropertyValue(managerType, instancePropertyName);
if (instance == null)
{
RimMindErrors.Warn($"[RimMind-Bridge-RimChat] TryGetManagerInstance: instance is null on {managerType.Name}.");
return null;
}

return instance;
}
=> Accessor.TryGetSingletonInstance(managerType, instancePropertyName);
}
}
Loading
Loading