diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 49b37c0..8ac3b6e 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -1056,11 +1056,7 @@ internal static class TalkCircleSystem OpinionStore.Get(person, other.Id) ?? 0); var now = OpinionStore.Get(person, other.Id) ?? 0; var shift = delta; - if (circle.VictimId is not null - && other.Id.Equals(circle.VictimId, StringComparison.Ordinal) - && circle.OpinionBaseline.TryGetValue(PairKey(person.Id, circle.VictimId), out var viewOfVictim) - && Conflict.ShouldDefend(viewOfVictim, rules) - && !person.Id.Equals(circle.VictimId, StringComparison.Ordinal)) + if (AllyOfVictim(circle, person.Id, rules) && AllyOfVictim(circle, other.Id, rules)) { shift = Math.Max(2, -delta / 4); } @@ -1076,6 +1072,26 @@ internal static class TalkCircleSystem } } + /// + /// Victim and anyone who joined on their side. Clash penalty is for opposite sides; allies + /// get a small plus instead — otherwise the victim's view of the defender falls like a fight. + /// + private static bool AllyOfVictim(ActiveTalkCircle circle, string personId, BehaviorDef? rules) + { + if (circle.VictimId is null) + { + return false; + } + + if (personId.Equals(circle.VictimId, StringComparison.Ordinal)) + { + return true; + } + + return circle.OpinionBaseline.TryGetValue(PairKey(personId, circle.VictimId), out var viewOfVictim) + && Conflict.ShouldDefend(viewOfVictim, rules); + } + private static void ApplyApology(School school, IReadOnlyList people, BehaviorDef? rules) { foreach (var person in people) diff --git a/tests/HSchool.Ai.Tests/ConflictTests.cs b/tests/HSchool.Ai.Tests/ConflictTests.cs index bccbadc..cd1f7d4 100644 --- a/tests/HSchool.Ai.Tests/ConflictTests.cs +++ b/tests/HSchool.Ai.Tests/ConflictTests.cs @@ -52,6 +52,16 @@ public class ConflictTests Assert.True(restored <= baseline); } + [Fact] + public void QuarrelOpinion_IsStrongerThanRudeTalk() + { + var (catalog, _) = Fixtures.Vanilla(); + var quarrel = -Conflict.QuarrelOpinionDelta(catalog.BehaviorRules); + var rude = Math.Abs(catalog.Topics["TopicRude"].OpinionShift); + Assert.True(quarrel > rude); + Assert.True(-Conflict.FightOpinionDelta(catalog.BehaviorRules) > quarrel); + } + [Fact] public void Catalog_HasNoHealthNeed() { diff --git a/tests/HSchool.People.Tests/AffinityTests.cs b/tests/HSchool.People.Tests/AffinityTests.cs index 19b5558..595b970 100644 --- a/tests/HSchool.People.Tests/AffinityTests.cs +++ b/tests/HSchool.People.Tests/AffinityTests.cs @@ -122,6 +122,30 @@ public class AffinityTests Assert.True((OpinionStore.Get(pupil, teacher.Id) ?? 0) < before); } + [Fact] + public void RosterJson_RoundTripsCrushesAndPartner() + { + var catalog = Fixtures.RomanceCatalog(); + var left = Staff("s1", female: false, "Bisexual"); + var right = Staff("s2", female: true, "Bisexual"); + SetOpinion(left, right.Id, 80); + SetOpinion(right, left.Id, 80); + var roster = new Roster([left, right], [], []); + Affinity.Refresh(catalog, roster, AsOf); + Assert.Equal(right.Id, left.Bonds!.PartnerId); + + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + Assert.Contains("\"partnerId\"", json, StringComparison.Ordinal); + Assert.Contains("\"crushes\"", json, StringComparison.Ordinal); + var loaded = RosterJson.Parse(json).ToRoster(); + var loadedLeft = loaded.People.First(person => person.Id.Equals(left.Id, StringComparison.Ordinal)); + var loadedRight = loaded.People.First(person => person.Id.Equals(right.Id, StringComparison.Ordinal)); + Assert.Equal(right.Id, loadedLeft.Bonds?.PartnerId); + Assert.Equal(left.Id, loadedRight.Bonds?.PartnerId); + Assert.Contains(right.Id, loadedLeft.Bonds!.Crushes); + Assert.Contains(left.Id, loadedRight.Bonds!.Crushes); + } + [Fact] public void AdjacentYearStudents_CanHaveSympathy() { diff --git a/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs b/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs index 1e85145..a656bfc 100644 --- a/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs +++ b/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs @@ -230,6 +230,34 @@ public class QuarrelFightTests } } + [Fact] + public void Defender_AndVictim_OpinionsMoveAsAllies() + { + var (school, pupils) = ThreePupilsOnBreak(); + using (school) + { + var bully = pupils[0]; + var victim = pupils[1]; + var defender = pupils[2]; + OpinionStore.Set(bully, victim.Id, -55); + OpinionStore.Set(victim, bully.Id, -55); + OpinionStore.Set(defender, victim.Id, 70); + PlaceAt(school, bully.Id, "corridor-1"); + PlaceAt(school, victim.Id, "corridor-1"); + PlaceAt(school, defender.Id, "corridor-1"); + + Assert.True(school.TryStartAction(bully.Id, TalkActions.Quarrel)); + TickWhile(school, personId => ActivityOf(school, personId) == TalkActions.Quarrel, bully.Id, victim.Id, defender.Id); + + Assert.True((OpinionStore.Get(defender, victim.Id) ?? 0) > 70); + Assert.True((OpinionStore.Get(victim, defender.Id) ?? 0) > 0); + Assert.True((OpinionStore.Get(defender, bully.Id) ?? 0) < 0); + Assert.True((OpinionStore.Get(victim, bully.Id) ?? 0) < -55); + Assert.True((OpinionStore.Get(bully, victim.Id) ?? 0) < -55); + Assert.True((OpinionStore.Get(bully, defender.Id) ?? 0) < 0); + } + } + private static (School School, Person First, Person Second) TwoPupilsOnBreak() { var (catalog, map) = Vanilla();