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
354 lines
13 KiB
C#
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),
|
|
};
|
|
}
|
|
}
|