Files
h-school/tests/HSchool.Ai.Tests/TalkCirclesTests.cs
T
Leonid Pershin 63cde56cc3 Merge branch 'phase/46-speech-home'
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/phases/09-social/README.md
#	src/HSchool.Server/Game/SchoolWorker.cs
#	tests/HSchool.Ai.Tests/TalkCirclesTests.cs
#	tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs
#	tests/HSchool.Simulation.Tests/TalkCircleTests.cs
2026-08-20 14:06:29 +03:00

354 lines
13 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));
}
[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 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: i * 137, 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 < 11_000; i++)
{
var freeId = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i, 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: i, 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),
};
}
}