RankInvitees already skipped enemies when a circle formed; a later Chat in the same node still joined without checking opinions. Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
15 KiB
C#
400 lines
15 KiB
C#
using HSchool.Ai;
|
|
using HSchool.Ai.Tests;
|
|
using HSchool.Content;
|
|
using HSchool.People;
|
|
|
|
namespace HSchool.Ai.Tests;
|
|
|
|
public class TalkCirclesTests
|
|
{
|
|
[Fact]
|
|
public void BoundToLesson_BlocksChatLeisure()
|
|
{
|
|
var (catalog, map, walks) = World();
|
|
var corridor = map.Rooms.First(room => room.Def == "Corridor").Id;
|
|
var decision = DecisionPlanner.Decide(
|
|
catalog,
|
|
map,
|
|
walks,
|
|
new ActorState(
|
|
corridor,
|
|
corridor,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
corridor,
|
|
Needs(),
|
|
Intent.None,
|
|
Talk: new TalkPlannerContext("p1", new Dictionary<string, int>(), new Dictionary<string, string>(), null, false, true, catalog.BehaviorRules)),
|
|
(_, _) => 0);
|
|
|
|
Assert.NotEqual(TalkActions.Chat, decision.StartAction);
|
|
Assert.NotEqual(TalkActions.StaffChat, decision.StartAction);
|
|
}
|
|
|
|
[Fact]
|
|
public void Student_DoesNotPickStaffChat()
|
|
{
|
|
var (catalog, map, walks) = World();
|
|
var teachersRoom = map.Rooms.First(room => room.Def == "TeachersRoom").Id;
|
|
var decision = DecisionPlanner.Decide(
|
|
catalog,
|
|
map,
|
|
walks,
|
|
new ActorState(
|
|
teachersRoom,
|
|
teachersRoom,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
teachersRoom,
|
|
Needs(social: 0.2f),
|
|
Intent.None,
|
|
Talk: new TalkPlannerContext("p1", new Dictionary<string, int>(), new Dictionary<string, string>(), null, false, true, catalog.BehaviorRules)),
|
|
(_, _) => 0);
|
|
|
|
Assert.NotEqual(TalkActions.StaffChat, decision.StartAction);
|
|
}
|
|
|
|
[Fact]
|
|
public void PhoneChat_NotOfferedWithoutPhone()
|
|
{
|
|
var (catalog, map, walks) = World();
|
|
var corridor = map.Rooms.First(room => room.Def == "Corridor").Id;
|
|
var decision = DecisionPlanner.Decide(
|
|
catalog,
|
|
map,
|
|
walks,
|
|
Actor(corridor, hasPhone: false),
|
|
(_, _) => 0);
|
|
|
|
Assert.NotEqual(TalkActions.PhoneChat, decision.StartAction);
|
|
}
|
|
|
|
[Fact]
|
|
public void HasPhone_DetectsBagPhone()
|
|
{
|
|
Assert.False(TalkCircles.HasPhone([]));
|
|
Assert.True(TalkCircles.HasPhone([new InventoryItem("Phone", "Black", 1f, ItemLocations.Bag)]));
|
|
}
|
|
|
|
[Fact]
|
|
public void NoSharedLanguage_WeakensOpinionShift()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var rules = catalog.BehaviorRules!;
|
|
var topic = catalog.Topics["TopicStudy"];
|
|
var from = Person("a", ["RussianLanguage"]);
|
|
var to = Person("b", ["English"]);
|
|
var withLanguage = TalkCircles.OpinionDelta(from, to, topic, 50f, sharedLanguage: true, TalkActions.Chat, ApparelIssue.None, rules, catalog);
|
|
var without = TalkCircles.OpinionDelta(from, to, topic, 50f, sharedLanguage: false, TalkActions.Chat, ApparelIssue.None, rules, catalog);
|
|
|
|
Assert.True(withLanguage > without);
|
|
Assert.True(without <= 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void HighCommunication_MovesOpinionMoreThanLow_OnSameTopic()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var rules = catalog.BehaviorRules!;
|
|
var topic = catalog.Topics["TopicSport"];
|
|
var from = Person("a", ["RussianLanguage"]);
|
|
var to = Person("b", ["RussianLanguage"]);
|
|
var high = TalkCircles.OpinionDelta(from, to, topic, 80f, true, TalkActions.Chat, ApparelIssue.None, rules, catalog);
|
|
var low = TalkCircles.OpinionDelta(from, to, topic, 20f, true, TalkActions.Chat, ApparelIssue.None, rules, catalog);
|
|
|
|
Assert.True(high > low);
|
|
}
|
|
|
|
[Fact]
|
|
public void NodeFriendScore_PullsTowardFriends_NotEnemies()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var opinions = new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["friend"] = 50,
|
|
["enemy"] = -50,
|
|
};
|
|
var nodes = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["friend"] = "corridor-a",
|
|
["enemy"] = "corridor-b",
|
|
};
|
|
var context = new TalkPlannerContext("self", opinions, nodes, null, false, true, catalog.BehaviorRules);
|
|
Assert.True(TalkCircles.NodeFriendScore("corridor-a", "self", context) > 0);
|
|
Assert.True(TalkCircles.NodeFriendScore("corridor-b", "self", context) < 0);
|
|
Assert.Equal(0f, TalkCircles.NodeFriendScore("corridor-a", "self", context with { FriendPullActive = false }));
|
|
}
|
|
|
|
[Fact]
|
|
public void LunchNodeBlockedByEnemy_OnlyWhenAnotherSeatExists()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["enemy"] = -50 };
|
|
var nodes = new Dictionary<string, string>(StringComparer.Ordinal) { ["enemy"] = "canteen-1" };
|
|
var context = new TalkPlannerContext("self", opinions, nodes, null, false, true, catalog.BehaviorRules);
|
|
Assert.True(TalkCircles.LunchNodeBlockedByEnemy("canteen-1", "self", context, hasAlternative: true));
|
|
Assert.False(TalkCircles.LunchNodeBlockedByEnemy("canteen-1", "self", context, hasAlternative: false));
|
|
Assert.False(TalkCircles.LunchNodeBlockedByEnemy("canteen-2", "self", context, hasAlternative: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void GossipAndOutgoing_UseTraitFields_NotHardcodedIdLists()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
Assert.True(catalog.Traits["Gossip"].TalkOpinionMultiplier > 1f);
|
|
Assert.Contains(catalog.Traits["Gossip"].TalkTagWeights, bias => bias.Tag.Equals("gossip", StringComparison.Ordinal));
|
|
Assert.True(catalog.Traits["Outgoing"].TalkInitiative > 1f);
|
|
Assert.Equal(1, catalog.Traits["Outgoing"].TalkCircleBonus);
|
|
Assert.True(TalkCircles.OpinionMultiplier(["Gossip"], catalog) > TalkCircles.OpinionMultiplier([], catalog));
|
|
Assert.True(TalkCircles.Initiative(["Outgoing"], catalog) > TalkCircles.Initiative([], catalog));
|
|
}
|
|
|
|
[Fact]
|
|
public void GossipTrait_BiasesPickTopicTowardGossipTag()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var gossip = Person("g", ["RussianLanguage"]) with { Traits = ["Gossip"] };
|
|
var plain = Person("p", ["RussianLanguage"]);
|
|
var gossipHits = 0;
|
|
var plainHits = 0;
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
var seed = Seed.Mix(i, 17, Seed.ApparelSalt);
|
|
var gossipId = TalkCircles.PickTopic(catalog, gossip, age: 14, seed);
|
|
var plainId = TalkCircles.PickTopic(catalog, plain, age: 14, seed);
|
|
if (gossipId is not null && catalog.Topics[gossipId].Tags.Contains("gossip", StringComparer.Ordinal))
|
|
{
|
|
gossipHits++;
|
|
}
|
|
|
|
if (plainId is not null && catalog.Topics[plainId].Tags.Contains("gossip", StringComparer.Ordinal))
|
|
{
|
|
plainHits++;
|
|
}
|
|
}
|
|
|
|
Assert.True(gossipHits > plainHits);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickTopic_IsNotAlwaysTheLastNameAlphabetically()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var picker = Person("a", ["RussianLanguage"]);
|
|
var picked = new HashSet<string>(StringComparer.Ordinal);
|
|
for (var i = 0; i < 80; i++)
|
|
{
|
|
var id = TalkCircles.PickTopic(catalog, picker, age: 14, Seed.Mix(i, 3, Seed.ApparelSalt));
|
|
Assert.NotNull(id);
|
|
picked.Add(id!);
|
|
}
|
|
|
|
Assert.True(picked.Count >= 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppearanceTopic_UsesDressAppropriateness()
|
|
{
|
|
Assert.Equal(2, TalkCircles.AppearanceModifier(ApparelIssue.None));
|
|
Assert.Equal(-2, TalkCircles.AppearanceModifier(ApparelIssue.Formality));
|
|
Assert.Equal(0, TalkCircles.AppearanceModifier(ApparelIssue.Color));
|
|
|
|
var (catalog, _, _) = World();
|
|
var rules = catalog.BehaviorRules!;
|
|
var topic = catalog.Topics["TopicAppearance"];
|
|
var from = Person("a", ["RussianLanguage"]);
|
|
var to = Person("b", ["RussianLanguage"]);
|
|
var compliment = TalkCircles.OpinionDelta(from, to, topic, 50f, true, TalkActions.Chat, ApparelIssue.None, rules, catalog);
|
|
var mockery = TalkCircles.OpinionDelta(from, to, topic, 50f, true, TalkActions.Chat, ApparelIssue.Formality, rules, catalog);
|
|
Assert.True(compliment > mockery);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommunicationGain_IsLessThanLessonSkillPerHour()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var rules = catalog.BehaviorRules!;
|
|
Assert.True(rules.TalkSkillPerHour < rules.LessonSkillPerHour);
|
|
var skill = catalog.Skills["Communication"];
|
|
var afterTalkHour = TalkCircles.CommunicationGain(50f, skill, 1f, rules);
|
|
Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour);
|
|
}
|
|
|
|
[Fact]
|
|
public void LessonWhisperFactor_CutsWhisper_LeavesOrdinaryChat()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var rules = catalog.BehaviorRules!;
|
|
Assert.True(TalkCircles.LessonWhisperFactor(TalkActions.Whisper, rules) < 1f);
|
|
Assert.Equal(1f, TalkCircles.LessonWhisperFactor(TalkActions.Chat, rules));
|
|
Assert.Equal(rules.LessonWhisperSkillFactor, TalkCircles.LessonWhisperFactor(TalkActions.Whisper, rules));
|
|
}
|
|
|
|
[Fact]
|
|
public void Outgoing_RaisesMaxSize_WhenTalkCircleMaxIsBelowHardLimit()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var rules = new BehaviorDef { DefName = "Rules", TalkCircleMin = 2, TalkCircleMax = 3 };
|
|
Assert.Equal(3, TalkCircles.MaxSize(TalkActions.Chat, rules, [], catalog));
|
|
Assert.Equal(4, TalkCircles.MaxSize(TalkActions.Chat, rules, ["Outgoing"], catalog));
|
|
Assert.Equal(4, TalkCircles.MaxSize(TalkActions.Chat, catalog.BehaviorRules, ["Outgoing"], catalog));
|
|
}
|
|
|
|
[Fact]
|
|
public void Joiner_IsRejectedWhenEitherSideIsEnemy()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var rules = catalog.BehaviorRules!;
|
|
var sitting = Person("a", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["c"] = 10 } };
|
|
var friend = Person("b", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 50 } };
|
|
var enemy = Person("c", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = -55 } };
|
|
Assert.True(TalkCircles.JoinerFits(friend, [sitting], rules));
|
|
Assert.False(TalkCircles.JoinerFits(enemy, [sitting], rules));
|
|
|
|
var hated = Person("d", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) };
|
|
var hater = Person("a", []) with { Opinions = new Dictionary<string, int>(StringComparer.Ordinal) { ["d"] = -55 } };
|
|
Assert.False(TalkCircles.JoinerFits(hated, [hater], rules));
|
|
}
|
|
|
|
[Fact]
|
|
public void StudyOnlyPolicy_NeverPicksNonStudyTopic()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var picker = Person("a", ["RussianLanguage"]);
|
|
for (var i = 0; i < 80; i++)
|
|
{
|
|
var id = TalkCircles.PickTopic(catalog, picker, age: 14, seed: Seed.Mix(i, 19, Seed.ApparelSalt), speechPolicy: SpeechPolicies.StudyOnly);
|
|
Assert.NotNull(id);
|
|
Assert.Contains(TopicTags.Study, catalog.Topics[id].Tags, StringComparer.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NoRudePolicy_NeverPicksRudeTopic()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var picker = Person("a", ["RussianLanguage"]);
|
|
var rudeUnderFree = 0;
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
var seed = Seed.Mix(i, 23, Seed.ApparelSalt);
|
|
var freeId = TalkCircles.PickTopic(catalog, picker, age: 14, seed: seed, speechPolicy: SpeechPolicies.Free);
|
|
if (freeId is not null && catalog.Topics[freeId].Tags.Contains(TopicTags.Rude, StringComparer.Ordinal))
|
|
{
|
|
rudeUnderFree++;
|
|
}
|
|
|
|
var filtered = TalkCircles.PickTopic(catalog, picker, age: 14, seed: seed, speechPolicy: SpeechPolicies.NoRude);
|
|
Assert.NotNull(filtered);
|
|
Assert.DoesNotContain(TopicTags.Rude, catalog.Topics[filtered].Tags, StringComparer.Ordinal);
|
|
}
|
|
|
|
Assert.True(rudeUnderFree > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void FamilyTaggedTopic_IsFromTopicDef()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var id = TalkCircles.PickTaggedTopic(catalog, TopicTags.Family, seed: 1);
|
|
Assert.NotNull(id);
|
|
Assert.Contains(TopicTags.Family, catalog.Topics[id].Tags, StringComparer.Ordinal);
|
|
var schoolShift = catalog.Topics[id].OpinionShift;
|
|
var home = (int)Math.Round(schoolShift * catalog.BehaviorRules!.HomeTalkOpinionScale, MidpointRounding.AwayFromZero);
|
|
Assert.True(Math.Abs(home) < Math.Abs(schoolShift) || schoolShift == 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Enemies_AreNotRankedAsInvitees()
|
|
{
|
|
var (catalog, _, _) = World();
|
|
var opinions = new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["b"] = 50,
|
|
["c"] = -50,
|
|
};
|
|
var initiator = Person("a", []) with { Opinions = opinions };
|
|
var ranked = TalkCircles.RankInvitees(
|
|
initiator,
|
|
[
|
|
new TalkCircles.Candidate("b", "c1", true, false, false),
|
|
new TalkCircles.Candidate("c", "c1", true, false, false),
|
|
],
|
|
catalog.BehaviorRules);
|
|
|
|
Assert.Equal(["b"], ranked);
|
|
}
|
|
|
|
private static ActorState Actor(string node, bool hasPhone)
|
|
{
|
|
var (catalog, _, _) = World();
|
|
return new ActorState(
|
|
node,
|
|
node,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
node,
|
|
Needs(social: 0.2f),
|
|
Intent.None,
|
|
Talk: new TalkPlannerContext("p1", new Dictionary<string, int>(), new Dictionary<string, string>(), null, hasPhone, true, catalog.BehaviorRules));
|
|
}
|
|
|
|
private static Dictionary<string, float> Needs(float social = 1f) => new(StringComparer.Ordinal)
|
|
{
|
|
["Social"] = social,
|
|
["Hunger"] = 1f,
|
|
["Sleep"] = 1f,
|
|
["Toilet"] = 1f,
|
|
};
|
|
|
|
private static (DefCatalog Catalog, MapLayout Map, WalkGraph Walks) World()
|
|
{
|
|
var (catalog, map) = Fixtures.Vanilla();
|
|
return (catalog, map, WalkGraph.Build(catalog, map));
|
|
}
|
|
|
|
private static Person Person(string id, string[] languages)
|
|
{
|
|
var skills = languages.ToDictionary(skill => skill, _ => 60, StringComparer.Ordinal);
|
|
skills["Communication"] = 50;
|
|
var cases = new CaseTable
|
|
{
|
|
Nom = "A",
|
|
Gen = "A",
|
|
Dat = "A",
|
|
Acc = "A",
|
|
Ins = "A",
|
|
Pre = "A",
|
|
};
|
|
return new Person
|
|
{
|
|
Id = id,
|
|
FamilyId = "f1",
|
|
Female = false,
|
|
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
Name = new PersonName("A", "B", "C", cases, cases, cases),
|
|
IsStudent = true,
|
|
IsStaff = false,
|
|
IsParent = false,
|
|
Numbers = new Dictionary<string, int>(StringComparer.Ordinal),
|
|
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
|
|
Skills = skills,
|
|
Traits = [],
|
|
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
|
|
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
|
|
};
|
|
}
|
|
}
|