From 395107c9594b7c62fb48acf6b5f91a6c55a7366d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 11:31:06 +0300 Subject: [PATCH] Add recess quarrels, rare yard and gym fights, and apologies. Bullies remember a victim by a trait field, a defender can join, staff break up a fight with a reprimand, and skip empty time clears leftover clashes so they cannot last forever. Co-authored-by: Cursor --- docs/phases/44-quarrel-fight.md | 30 +- src/HSchool.Ai/Conflict.cs | 211 ++++++++ src/HSchool.Ai/Decision.cs | 10 + src/HSchool.Ai/TalkCircles.cs | 14 +- src/HSchool.Content/PeopleDefValidator.cs | 25 + src/HSchool.Content/PeopleDefs.cs | 32 ++ src/HSchool.Content/TalkActions.cs | 24 +- src/HSchool.People/Roster.cs | 6 + src/HSchool.People/Seed.cs | 1 + .../mods/core/defs/actions/living.jsonc | 45 ++ .../mods/core/defs/behavior/rules.jsonc | 7 + .../mods/core/defs/traits/traits.jsonc | 4 + .../mods/core/localizations/en.jsonc | 9 + .../mods/core/localizations/ru.jsonc | 9 + src/HSchool.Simulation/PersonLog.cs | 24 + src/HSchool.Simulation/PresenceSystem.cs | 115 +++- src/HSchool.Simulation/School.cs | 5 + src/HSchool.Simulation/TalkCircleSystem.cs | 506 +++++++++++++++++- tests/HSchool.Ai.Tests/ConflictTests.cs | 101 ++++ .../QuarrelFightTests.cs | 349 ++++++++++++ 20 files changed, 1504 insertions(+), 23 deletions(-) create mode 100644 src/HSchool.Ai/Conflict.cs create mode 100644 tests/HSchool.Ai.Tests/ConflictTests.cs create mode 100644 tests/HSchool.Simulation.Tests/QuarrelFightTests.cs diff --git a/docs/phases/44-quarrel-fight.md b/docs/phases/44-quarrel-fight.md index d771f99..b259595 100644 --- a/docs/phases/44-quarrel-fight.md +++ b/docs/phases/44-quarrel-fight.md @@ -11,24 +11,24 @@ ## Задачи -- [ ] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора -- [ ] Действие драки: редко, двор или зал; коротко; без травм и медпункта -- [ ] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName -- [ ] Задира держит id жертвы, пока та в ростере; не меняет цель каждый день -- [ ] Третий с высоким плюсом к жертве может вступить в ссору на её стороне -- [ ] Действие «извиниться» после ссоры/выговора в одном узле возвращает часть мнения -- [ ] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается -- [ ] Лог обоих; кабинет директора не заводим -- [ ] Игрок не приказывает разнять +- [x] Действие ссоры: 2 (иногда 3), перемена, мнение падает сильнее грубого разговора +- [x] Действие драки: редко, двор или зал; коротко; без травм и медпункта +- [x] Триггер: низкое взаимное мнение, задира/вспыльчивый — поля, не if по defName +- [x] Задира держит id жертвы, пока та в ростере; не меняет цель каждый день +- [x] Третий с высоким плюсом к жертве может вступить в ссору на её стороне +- [x] Действие «извиниться» после ссоры/выговора в одном узле возвращает часть мнения +- [x] Сотрудник в узле обрывает драку и делает выговор; нет сотрудника — действие просто кончается +- [x] Лог обоих; кабинет директора не заводим +- [x] Игрок не приказывает разнять ## Тесты, без которых фаза не закрыта -- [ ] Двое врагов на дворе чаще ссорятся, чем двое друзей -- [ ] Задира два дня подряд целится в того же человека, если тот ещё в школе -- [ ] Извинение поднимает мнение, но не выше, чем до ссоры -- [ ] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется -- [ ] Выговор после обрыва есть, если в узле был сотрудник -- [ ] Skip пустого времени не оставляет «вечную драку» в состоянии +- [x] Двое врагов на дворе чаще ссорятся, чем двое друзей +- [x] Задира два дня подряд целится в того же человека, если тот ещё в школе +- [x] Извинение поднимает мнение, но не выше, чем до ссоры +- [x] Драка не меняет нужду «здоровье» — такой нужды нет и не появляется +- [x] Выговор после обрыва есть, если в узле был сотрудник +- [x] Skip пустого времени не оставляет «вечную драку» в состоянии ## Критерий готовности diff --git a/src/HSchool.Ai/Conflict.cs b/src/HSchool.Ai/Conflict.cs new file mode 100644 index 0000000..17bcce2 --- /dev/null +++ b/src/HSchool.Ai/Conflict.cs @@ -0,0 +1,211 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Ai; + +/// Quarrel, fight and apology math. No world, no clock — inputs to an output. +public static class Conflict +{ + public const int OpinionFloor = -100; + + public static bool RemembersVictim(IReadOnlyList traitIds, DefCatalog catalog) + { + foreach (var traitId in traitIds) + { + if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.RemembersVictim) + { + return true; + } + } + + return false; + } + + public static float ConflictChance(IReadOnlyList traitIds, DefCatalog catalog) + { + var factor = 1f; + foreach (var traitId in traitIds) + { + if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.ConflictChance > 0f) + { + factor *= trait.ConflictChance; + } + } + + return factor; + } + + public static float ApologyChance(IReadOnlyList traitIds, DefCatalog catalog) + { + var factor = 1f; + foreach (var traitId in traitIds) + { + if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.ApologyChance > 0f) + { + factor *= trait.ApologyChance; + } + } + + return factor; + } + + public static bool WantsQuarrel( + int? opinionOfTarget, + BehaviorDef? rules, + string? rememberedVictimId, + string targetId) + { + if (rememberedVictimId is not null && rememberedVictimId.Equals(targetId, StringComparison.Ordinal)) + { + return true; + } + + return TalkCircles.IsEnemy(opinionOfTarget, rules); + } + + /// Friends score 0; rivals score the trait-scaled base. That is "чаще", not a coin flip. + public static float QuarrelChance( + int? opinionOfTarget, + IReadOnlyList traits, + DefCatalog catalog, + BehaviorDef? rules, + string? rememberedVictimId, + string targetId) + { + if (!WantsQuarrel(opinionOfTarget, rules, rememberedVictimId, targetId)) + { + return 0f; + } + + return Math.Clamp((rules?.QuarrelChance ?? 1f) * ConflictChance(traits, catalog), 0f, 1f); + } + + public static bool RollStarts(float chance, int seed) => + chance >= 1f || (chance > 0f && TalkCircles.Roll01(seed) < chance); + + public static bool RollFight(IReadOnlyList traits, DefCatalog catalog, BehaviorDef? rules, int seed) + { + var chance = Math.Clamp((rules?.FightChance ?? 0.1f) * ConflictChance(traits, catalog), 0f, 0.45f); + return chance > 0f && TalkCircles.Roll01(seed) < chance; + } + + public static bool CanFightHere(string? roomDef) => + roomDef is not null + && (roomDef.Equals("SchoolYard", StringComparison.Ordinal) + || roomDef.Equals("GymHall", StringComparison.Ordinal)); + + public static string FightActionFor(string roomDef) => + roomDef.Equals("GymHall", StringComparison.Ordinal) ? TalkActions.FightGym : TalkActions.Fight; + + public static string QuarrelActionFor(string roomDef) => + roomDef.Equals("SchoolYard", StringComparison.Ordinal) ? TalkActions.QuarrelYard : TalkActions.Quarrel; + + public static bool ShouldDefend(int? opinionOfVictim, BehaviorDef? rules) => + opinionOfVictim >= (rules?.DefendOpinionMin ?? 40); + + public static int QuarrelOpinionDelta(BehaviorDef? rules) => + rules?.QuarrelOpinionShift ?? -8; + + public static int FightOpinionDelta(BehaviorDef? rules) => + rules?.FightOpinionShift ?? -16; + + /// Returns part of , never above . + public static int ApologyOpinion(int current, int baseline, int lost, BehaviorDef? rules) + { + var fraction = Math.Clamp(rules?.ApologyRestoreFraction ?? 0.5f, 0f, 1f); + var restored = Math.Max(0, (int)Math.Round(lost * fraction, MidpointRounding.AwayFromZero)); + if (restored < 1 && lost > 0) + { + restored = 1; + } + + return Math.Min(baseline, current + restored); + } + + public static bool HasRival(TalkPlannerContext context) + { + if (context.SelfId is null) + { + return false; + } + + foreach (var (personId, _) in context.PersonNodes) + { + if (personId.Equals(context.SelfId, StringComparison.Ordinal)) + { + continue; + } + + context.Opinions.TryGetValue(personId, out var opinion); + if (WantsQuarrel(opinion, context.Rules, context.BullyVictimId, personId)) + { + return true; + } + } + + return false; + } + + public static float NodeRivalScore(string nodeId, string selfId, TalkPlannerContext context) + { + var score = 0f; + foreach (var (personId, otherNode) in context.PersonNodes) + { + if (personId.Equals(selfId, StringComparison.Ordinal) || otherNode != nodeId) + { + continue; + } + + context.Opinions.TryGetValue(personId, out var opinion); + if (WantsQuarrel(opinion, context.Rules, context.BullyVictimId, personId)) + { + score += 5f; + } + } + + return score; + } + + public static bool HasHealthNeed(DefCatalog catalog) => + catalog.Needs.Values.Any(need => + !need.Abstract && need.DefName.Equals("Health", StringComparison.OrdinalIgnoreCase)); + + /// + /// Same victim across days while they stay on the roster and opinion is not at the floor. + /// Picks by mixed seed only when memory is empty or stale — never string.GetHashCode. + /// + public static string? ResolveVictim(Person actor, IReadOnlyList roster, DefCatalog catalog, int seed) + { + if (!RemembersVictim(actor.Traits, catalog)) + { + return null; + } + + if (actor.BullyVictimId is { } remembered + && roster.Any(person => + person.Id.Equals(remembered, StringComparison.Ordinal) + && !person.Id.Equals(actor.Id, StringComparison.Ordinal)) + && (OpinionStore.Get(actor, remembered) ?? 0) > OpinionFloor) + { + return remembered; + } + + var candidates = roster + .Where(person => + person.IsStudent + && !person.Id.Equals(actor.Id, StringComparison.Ordinal) + && (OpinionStore.Get(actor, person.Id) ?? 0) > OpinionFloor) + .OrderBy(person => person.Id, StringComparer.Ordinal) + .ToArray(); + if (candidates.Length == 0) + { + actor.BullyVictimId = null; + return null; + } + + var index = (seed & int.MaxValue) % candidates.Length; + var pick = candidates[index].Id; + actor.BullyVictimId = pick; + return pick; + } +} diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs index 6cd422d..1c305b8 100644 --- a/src/HSchool.Ai/Decision.cs +++ b/src/HSchool.Ai/Decision.cs @@ -291,6 +291,11 @@ public static class DecisionPlanner return Intent.None; } + if (TalkActions.IsQuarrel(action.DefName) && !Conflict.HasRival(state.Talk)) + { + return Intent.None; + } + if (RoomFor(catalog, map, walks, state, occupied, action) is null) { return Intent.None; @@ -398,6 +403,7 @@ public static class DecisionPlanner if (action.Abstract || !need.Equals(action.Need, StringComparison.Ordinal) || !RoleFits(action, state) + || TalkActions.IsConflict(action.DefName) || (state.BoundToLesson && TalkCircles.BlocksOnLesson(action.DefName))) { continue; @@ -475,6 +481,10 @@ public static class DecisionPlanner } var score = -cost + TalkCircles.NodeFriendScore(nodeId, state.Talk.SelfId ?? "", state.Talk); + if (TalkActions.IsQuarrel(action.DefName)) + { + score += Conflict.NodeRivalScore(nodeId, state.Talk.SelfId ?? "", state.Talk); + } if (score > bestScore || (score == bestScore && (best is null || string.CompareOrdinal(nodeId, best) < 0))) { diff --git a/src/HSchool.Ai/TalkCircles.cs b/src/HSchool.Ai/TalkCircles.cs index b249432..cd6702f 100644 --- a/src/HSchool.Ai/TalkCircles.cs +++ b/src/HSchool.Ai/TalkCircles.cs @@ -11,7 +11,8 @@ public readonly record struct TalkPlannerContext( string? ClassId, bool HasPhone, bool FriendPullActive, - BehaviorDef? Rules); + BehaviorDef? Rules, + string? BullyVictimId = null); /// /// Pure talk-circle rules: who joins, which topic, opinion and skill math. No world access. @@ -19,10 +20,19 @@ public readonly record struct TalkPlannerContext( public static class TalkCircles { public static bool BlocksOnLesson(string actionId) => - TalkActions.IsTalk(actionId) && !TalkActions.IsWhisper(actionId); + TalkActions.IsTalk(actionId) && !TalkActions.IsWhisper(actionId) && !TalkActions.IsFight(actionId); public static int MaxSize(string actionId, BehaviorDef? rules, IReadOnlyList traitIds, DefCatalog catalog) { + if (TalkActions.IsFight(actionId) || TalkActions.IsApologize(actionId)) + { + return 2; + } + + if (TalkActions.IsQuarrel(actionId)) + { + return 3; + } if (actionId.Equals(TalkActions.PhoneChat, StringComparison.Ordinal)) { return 2; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index cf8e8fa..7bbfb09 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -266,6 +266,16 @@ internal static class PeopleDefValidator { throw new ContentLoadException($"TraitDef '{trait.DefName}' whisperCatchMultiplier cannot be negative."); } + + if (trait.ConflictChance < 0f) + { + throw new ContentLoadException($"TraitDef '{trait.DefName}' conflictChance cannot be negative."); + } + + if (trait.ApologyChance < 0f) + { + throw new ContentLoadException($"TraitDef '{trait.DefName}' apologyChance cannot be negative."); + } } private static void ValidateNeed(NeedDef need) @@ -612,6 +622,21 @@ internal static class PeopleDefValidator { throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' teacher-talk weights cannot be negative."); } + + if (behavior.QuarrelChance is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' quarrelChance must be 0–1."); + } + + if (behavior.FightChance is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' fightChance must be 0–1."); + } + + if (behavior.ApologyRestoreFraction is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apologyRestoreFraction must be 0–1."); + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index ed0c57c..feb575a 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -215,6 +215,20 @@ public sealed class TraitDef : Def /// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1. /// public float WhisperCatchMultiplier { get; init; } = 1f; + + /// + /// Multiplies quarrel and fight chance. Hot-tempered and bully above 1. Missing is 1. + /// + public float ConflictChance { get; init; } = 1f; + + /// + /// This person keeps one victim id until they leave or opinion hits the floor. + /// A field, not a defName == "Bully" branch. + /// + public bool RemembersVictim { get; init; } + + /// Multiplies the chance to start an apology. Bully below 1. Missing is 1. + public float ApologyChance { get; init; } = 1f; } /// Conversation subject. Tags gate appropriateness; language is optional skill help. @@ -451,6 +465,24 @@ public sealed class BehaviorDef : Def /// Per-decision chance a pupil in class tries to start a whisper, before initiative. public float WhisperStartChance { get; init; } = 0.12f; + /// Rivals always roll this before traits. 1 means every enemy pair can start a quarrel. + public float QuarrelChance { get; init; } = 1f; + + /// Base chance a yard or gym clash becomes a fight. Traits scale it; kept rare. + public float FightChance { get; init; } = 0.1f; + + /// Opinion shift per other participant when a quarrel ends. Stronger than rude talk (−3). + public int QuarrelOpinionShift { get; init; } = -8; + + /// Opinion shift when a fight ends. No health need is touched. + public int FightOpinionShift { get; init; } = -16; + + /// Fraction of lost opinion an apology returns. Never climbs past the pre-quarrel value. + public float ApologyRestoreFraction { get; init; } = 0.5f; + + /// Opinion of the victim at or above this — a third person may join the quarrel. + public int DefendOpinionMin { get; init; } = 40; + public static IReadOnlyList DefaultOpinionBands { get; } = [ new() { Min = 70, Id = "OpinionCloseFriend" }, diff --git a/src/HSchool.Content/TalkActions.cs b/src/HSchool.Content/TalkActions.cs index 000cc0f..c3f40dd 100644 --- a/src/HSchool.Content/TalkActions.cs +++ b/src/HSchool.Content/TalkActions.cs @@ -8,6 +8,11 @@ public static class TalkActions public const string PhoneChat = "PhoneChat"; public const string Whisper = "Whisper"; public const string TeacherTalk = "TeacherTalk"; + public const string Quarrel = "Quarrel"; + public const string QuarrelYard = "QuarrelYard"; + public const string Fight = "Fight"; + public const string FightGym = "FightGym"; + public const string Apologize = "Apologize"; public static bool IsTalk(string? actionId) => actionId is not null @@ -15,7 +20,24 @@ public static class TalkActions || actionId.Equals(StaffChat, StringComparison.Ordinal) || actionId.Equals(PhoneChat, StringComparison.Ordinal) || actionId.Equals(Whisper, StringComparison.Ordinal) - || actionId.Equals(TeacherTalk, StringComparison.Ordinal)); + || actionId.Equals(TeacherTalk, StringComparison.Ordinal) + || IsConflict(actionId)); + + public static bool IsQuarrel(string? actionId) => + actionId is not null + && (actionId.Equals(Quarrel, StringComparison.Ordinal) + || actionId.Equals(QuarrelYard, StringComparison.Ordinal)); + + public static bool IsFight(string? actionId) => + actionId is not null + && (actionId.Equals(Fight, StringComparison.Ordinal) + || actionId.Equals(FightGym, StringComparison.Ordinal)); + + public static bool IsApologize(string? actionId) => + actionId is not null && actionId.Equals(Apologize, StringComparison.Ordinal); + + public static bool IsConflict(string? actionId) => + IsQuarrel(actionId) || IsFight(actionId) || IsApologize(actionId); public static bool IsWhisper(string? actionId) => actionId is not null && actionId.Equals(Whisper, StringComparison.Ordinal); diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 9b58c68..d3582d1 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -55,6 +55,12 @@ public sealed record Person /// What this person thinks of others, A→B. Sparse; zero is not stored. public IReadOnlyDictionary Opinions { get; init; } = new Dictionary(StringComparer.Ordinal); + /// + /// Victim a bully keeps aiming at. Cleared when they leave the roster or opinion hits the floor. + /// Null on everyone without a remembers-victim trait. + /// + public string? BullyVictimId { get; set; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 2ac9379..da7b385 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -18,6 +18,7 @@ public static class Seed public const int ClimatePresetSalt = 10; public const int ApparelSalt = 11; public const int WhisperSalt = 12; + public const int ConflictSalt = 13; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc index 46f1cba..74eb0b0 100644 --- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc +++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc @@ -86,6 +86,51 @@ "roles": ["student", "staff"], "weight": 1, }, + { + "defName": "Quarrel", + "room": "Corridor", + "minutes": 6, + "need": "Social", + "needGain": 0.35, + "roles": ["student"], + "weight": 4, + }, + { + "defName": "QuarrelYard", + "room": "SchoolYard", + "minutes": 6, + "need": "Social", + "needGain": 0.35, + "roles": ["student"], + "weight": 4, + }, + { + "defName": "Fight", + "room": "SchoolYard", + "minutes": 3, + "need": "Social", + "needGain": 0.2, + "roles": ["student"], + "weight": 0, + }, + { + "defName": "FightGym", + "room": "GymHall", + "minutes": 3, + "need": "Social", + "needGain": 0.2, + "roles": ["student"], + "weight": 0, + }, + { + "defName": "Apologize", + "room": "Corridor", + "minutes": 4, + "need": "Social", + "needGain": 0.15, + "roles": ["student"], + "weight": 0, + }, { "defName": "ChangeClothesMale", "room": "MaleChangingRoom", diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 9cf4bd0..94c09a6 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -88,4 +88,11 @@ "teacherQuestionWeight": 2, "teacherPraiseWeight": 2, "teacherReprimandWeight": 1, + // Quarrel and fight (slice 9 phase 44). Fight stays rare even with hot-tempered. + "quarrelChance": 1, + "fightChance": 0.1, + "quarrelOpinionShift": -8, + "fightOpinionShift": -16, + "apologyRestoreFraction": 0.5, + "defendOpinionMin": 40, } diff --git a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc index c917f6f..840de75 100644 --- a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc +++ b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc @@ -24,6 +24,9 @@ "incompatible": ["Kind", "Quiet"], "roles": ["student"], "age": { "min": 7, "max": 18 }, + "conflictChance": 1.6, + "remembersVictim": true, + "apologyChance": 0.35, "skillModifiers": [ { "skill": "Strength", "offset": 8 }, { "skill": "Literature", "offset": -4 }, @@ -71,6 +74,7 @@ "weight": 5, "incompatible": ["Quiet"], "wageAsk": 6, + "conflictChance": 1.8, }, { "defName": "Kind", diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index a0e28b9..69c2463 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -10,6 +10,11 @@ "TeacherTalk": "Teacher talk", "WalkCorridor": "Walk the corridor", "WalkYard": "Walk the yard", + "Quarrel": "Quarrel", + "QuarrelYard": "Yard quarrel", + "Fight": "Fight", + "FightGym": "Gym fight", + "Apologize": "Apology", "Behavior": "Behavior rules", "Chair": "Chair", "DirectorsChair": "Principal's chair", @@ -198,6 +203,10 @@ "TalkEnded": "talked about {0}", "Whispered": "whispered", "TeacherInterrupted": "the teacher cut them off", + "Quarreled": "quarreled", + "Fought": "fought", + "Apologized": "apologized", + "Reprimanded": "reprimanded", "TopicStudy": "schoolwork", "TopicGames": "games", "TopicFood": "food", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index f54417f..08a7891 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -10,6 +10,11 @@ "TeacherTalk": "Разговор учителя", "WalkCorridor": "Прогулка по коридору", "WalkYard": "Прогулка во дворе", + "Quarrel": "Ссора", + "QuarrelYard": "Ссора во дворе", + "Fight": "Драка", + "FightGym": "Драка в зале", + "Apologize": "Извинение", "Behavior": "Правила поведения", "Chair": "Стул", "DirectorsChair": "Кресло директора", @@ -198,6 +203,10 @@ "TalkEnded": "говорил о {0}", "Whispered": "шептались", "TeacherInterrupted": "учитель оборвал", + "Quarreled": "ссорились", + "Fought": "дрались", + "Apologized": "извинился", + "Reprimanded": "выговор", "TopicStudy": "учёбе", "TopicGames": "играх", "TopicFood": "еде", diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index f462999..9563fd7 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -15,6 +15,10 @@ public static class PersonLogTypes public const string TalkEnded = "talk-ended"; public const string Whispered = "whispered"; public const string TeacherInterrupted = "teacher-interrupted"; + public const string Quarreled = "quarreled"; + public const string Fought = "fought"; + public const string Apologized = "apologized"; + public const string Reprimanded = "reprimanded"; public const string ApparelReplaced = "apparel-replaced"; public const string ApparelChanged = "apparel-changed"; public const string LessonNoTeacher = "lesson-no-teacher"; @@ -41,6 +45,26 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, return catalog.Text(locale, "TeacherInterrupted"); } + if (Type.Equals(PersonLogTypes.Quarreled, StringComparison.Ordinal)) + { + return catalog.Text(locale, "Quarreled"); + } + + if (Type.Equals(PersonLogTypes.Fought, StringComparison.Ordinal)) + { + return catalog.Text(locale, "Fought"); + } + + if (Type.Equals(PersonLogTypes.Apologized, StringComparison.Ordinal)) + { + return catalog.Text(locale, "Apologized"); + } + + if (Type.Equals(PersonLogTypes.Reprimanded, StringComparison.Ordinal)) + { + return catalog.Text(locale, "Reprimanded"); + } + if (ThingDef is null) { return Type; diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index e05d0f8..efa8cdf 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -333,7 +333,7 @@ internal static class PresenceSystem : slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index); if (activity.IsActive && TalkActions.IsTalk(activity.ActionId)) { - if (!bound || TalkActions.IsWhisper(activity.ActionId)) + if (!bound || TalkActions.IsWhisper(activity.ActionId) || TalkActions.IsFight(activity.ActionId)) { return null; } @@ -434,7 +434,25 @@ internal static class PresenceSystem if (decision.StartAction is not null && !activity.IsActive) { - return decision.StartAction; + var start = decision.StartAction; + var location = state.NodeId is null ? null : school.Map?.NodeDef(state.NodeId); + if (TalkActions.IsQuarrel(start) + && location is not null + && Conflict.CanFightHere(location) + && Conflict.RollFight( + person.Traits, + school.Catalog!, + school.Catalog.BehaviorRules, + Seed.Mix( + school.PeopleSeed, + person.Id, + DateOnly.FromDateTime(school.Clock.Time).DayNumber, + Seed.ConflictSalt + 2))) + { + start = Conflict.FightActionFor(location); + } + + return start; } if (!activity.IsActive @@ -444,6 +462,20 @@ internal static class PresenceSystem return TalkActions.Whisper; } + if (!activity.IsActive + && decision.WalkTo is null + && ShouldStartFightOnPe(school, person, state)) + { + return TalkActions.FightGym; + } + + if (!activity.IsActive + && decision.WalkTo is null + && ShouldStartApology(school, person, state)) + { + return TalkActions.Apologize; + } + return null; } @@ -476,6 +508,82 @@ internal static class PresenceSystem return TalkCircles.WhisperCaught(roll, chance, 0.5f, 1f); } + private static bool ShouldStartFightOnPe(School school, Person person, ActorState state) + { + if (!state.BoundToLesson + || !person.IsStudent + || state.IsWalking + || state.NodeId is null + || school.Catalog is null) + { + return false; + } + + var location = school.Map?.NodeDef(state.NodeId); + if (location is null || !location.Equals("GymHall", StringComparison.Ordinal)) + { + return false; + } + + if (!Conflict.HasRival(state.Talk)) + { + return false; + } + + return Conflict.RollFight( + person.Traits, + school.Catalog, + school.Catalog.BehaviorRules, + Seed.Mix( + school.PeopleSeed, + person.Id, + DateOnly.FromDateTime(school.Clock.Time).DayNumber, + Seed.ConflictSalt + 3 + (school.Clock.Time.Minute / 2))); + } + + private static bool ShouldStartApology(School school, Person person, ActorState state) + { + if (!person.IsStudent || state.IsWalking || state.NodeId is null || school.Catalog is null) + { + return false; + } + + var hasDebtHere = false; + foreach (var debt in school.ApologyDebts.Values) + { + if (!debt.FromId.Equals(person.Id, StringComparison.Ordinal)) + { + continue; + } + + if (!state.Talk.PersonNodes.TryGetValue(debt.ToId, out var node) + || !node.Equals(state.NodeId, StringComparison.Ordinal)) + { + continue; + } + + hasDebtHere = true; + break; + } + + if (!hasDebtHere) + { + return false; + } + + var chance = Math.Clamp( + Conflict.ApologyChance(person.Traits, school.Catalog), + 0f, + 1f); + return Conflict.RollStarts( + chance, + Seed.Mix( + school.PeopleSeed, + person.Id, + DateOnly.FromDateTime(school.Clock.Time).DayNumber, + Seed.ConflictSalt + 4)); + } + private static Dictionary<(string Node, string Thing), int> SnapshotOccupied(School school, string exceptId) { var occupied = new Dictionary<(string Node, string Thing), int>(); @@ -597,7 +705,8 @@ internal static class PresenceSystem person.ClassId, TalkCircles.HasPhone(person.Items), friendPull, - school.Catalog?.BehaviorRules); + school.Catalog?.BehaviorRules, + person.BullyVictimId); } private static Dictionary SnapshotPersonNodes(School school) diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 65f2d9a..a69035d 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -136,6 +136,8 @@ public sealed class School : IDisposable internal Dictionary TalkCircleByPerson { get; } = new(StringComparer.Ordinal); + internal Dictionary ApologyDebts { get; } = new(StringComparer.Ordinal); + internal void ResetDayLog() { _dayLog.Clear(); @@ -260,6 +262,7 @@ public sealed class School : IDisposable var before = Clock.Time; Clock.JumpTo(next.Value); + TalkCircleSystem.AbandonAll(this); ResetDayLog(); var peopleChanged = TryYearlyIntake(before, next.Value); peopleChanged |= TryApplicantRefresh(); @@ -494,3 +497,5 @@ public readonly record struct SkipEmptyResult(SkipEmptyError Error, DateTime? Ti public static SkipEmptyResult Fail(SkipEmptyError error) => new(error, null, false); } + +internal readonly record struct ApologyDebt(string FromId, string ToId, int Baseline, int Lost); diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 914f2cd..ef73e1d 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -21,6 +21,12 @@ internal sealed class ActiveTalkCircle /// Set once a teacher in the room has rolled to notice this whisper. public bool CatchChecked { get; set; } + + /// The person a quarrel or fight is aimed at. Null on ordinary talk. + public string? VictimId { get; init; } + + /// Opinion before this clash, keyed fromId>toId. Used to cap apologies. + public Dictionary OpinionBaseline { get; init; } = new(StringComparer.Ordinal); } /// Forms 2–4 person talk circles, applies outcomes, writes topic log lines. @@ -32,6 +38,33 @@ internal static class TalkCircleSystem public static bool IsInCircle(School school, string personId) => school.TalkCircleByPerson.ContainsKey(personId); + /// + /// Drops leftover circles when skip jumps the clock. Remaining minutes are not applied — the + /// campus was empty, so a fight must not sit in state until Monday. + /// + public static void AbandonAll(School school) + { + foreach (var circle in school.TalkCirclesById.Values.ToArray()) + { + foreach (var member in circle.Members) + { + school.TalkCircleByPerson.Remove(member); + ClearActivity(school, member); + } + + school.TalkCirclesById.Remove(circle.Id); + } + + school.ApologyDebts.Clear(); + school.World.Query(in People, (ref PersonActivity activity) => + { + if (TalkActions.IsConflict(activity.ActionId)) + { + activity = PersonActivity.Idle; + } + }); + } + /// Ends an active circle when duty overrides break talk (bell rang). public static void Interrupt(School school, string personId) { @@ -41,6 +74,12 @@ internal static class TalkCircleSystem return; } + if (TalkActions.IsConflict(circle.ActionId)) + { + FinishConflict(school, circle, reprimanded: false); + return; + } + Finish(school, circle); } @@ -56,11 +95,18 @@ internal static class TalkCircleSystem return false; } - if (school.TalkCircleByPerson.ContainsKey(personId)) + if (school.TalkCircleByPerson.TryGetValue(personId, out var alreadyId) + && school.TalkCirclesById.TryGetValue(alreadyId, out var already) + && already.ActionId.Equals(actionId, StringComparison.Ordinal)) { return true; } + if (school.TalkCircleByPerson.ContainsKey(personId)) + { + Interrupt(school, personId); + } + var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal)); if (person is null || !RoleFits(action, person)) { @@ -85,6 +131,11 @@ internal static class TalkCircleSystem return false; } + if (TalkActions.IsConflict(actionId)) + { + return TryStartConflict(school, person, nodeId, action); + } + var location = school.Map.NodeDef(nodeId); if (location is null || !location.Equals(action.Room, StringComparison.Ordinal)) { @@ -118,6 +169,13 @@ internal static class TalkCircleSystem { circle.RemainingMinutes -= minutes; SyncActivity(school, circle); + if (TalkActions.IsFight(circle.ActionId) && StaffStandingIn(school, circle.NodeId) is not null) + { + FinishConflict(school, circle, reprimanded: true); + completed.AddRange(circle.Members); + continue; + } + if (TalkActions.IsWhisper(circle.ActionId) && TryCatchWhisper(school, circle)) { continue; @@ -128,7 +186,15 @@ internal static class TalkCircleSystem continue; } - Finish(school, circle); + if (TalkActions.IsConflict(circle.ActionId)) + { + FinishConflict(school, circle, reprimanded: false); + } + else + { + Finish(school, circle); + } + completed.AddRange(circle.Members); } @@ -575,6 +641,442 @@ internal static class TalkCircleSystem return found; } + private static bool TryStartConflict(School school, Person initiator, string nodeId, ActionDef action) + { + var location = school.Map!.NodeDef(nodeId); + if (TalkActions.IsApologize(action.DefName)) + { + return TryStartApology(school, initiator, nodeId, action); + } + + if (location is null || !location.Equals(action.Room, StringComparison.Ordinal)) + { + return false; + } + + if (TalkActions.IsFight(action.DefName) && !Conflict.CanFightHere(location)) + { + return false; + } + + var open = FindOpenConflict(school, nodeId, action.DefName); + if (open is not null && TalkActions.IsQuarrel(action.DefName)) + { + return TryJoinAsDefender(school, open, initiator, action); + } + + if (open is not null) + { + return false; + } + + var targetId = PickConflictTarget(school, initiator, nodeId); + if (targetId is null) + { + return false; + } + + var rules = school.Catalog!.BehaviorRules; + var chance = Conflict.QuarrelChance( + OpinionStore.Get(initiator, targetId), + initiator.Traits, + school.Catalog, + rules, + initiator.BullyVictimId, + targetId); + var seed = Seed.Mix( + school.PeopleSeed, + initiator.Id, + DateOnly.FromDateTime(school.Clock.Time).DayNumber, + Seed.ConflictSalt); + if (!TalkActions.IsFight(action.DefName) && !Conflict.RollStarts(chance, seed)) + { + return false; + } + + if (TalkActions.IsFight(action.DefName) + && !Conflict.WantsQuarrel( + OpinionStore.Get(initiator, targetId), + rules, + initiator.BullyVictimId, + targetId)) + { + return false; + } + + var members = new List { initiator.Id, targetId }; + members.Sort(StringComparer.Ordinal); + var circle = new ActiveTalkCircle + { + Id = $"conflict-{initiator.Id}-{nodeId}-{school.Clock.Time.Ticks}", + ActionId = action.DefName, + TopicId = action.DefName, + NodeId = nodeId, + Members = members, + RemainingMinutes = action.Minutes, + VictimId = targetId, + OpinionBaseline = CaptureBaselines(school, members), + }; + Register(school, circle, action); + if (TalkActions.IsQuarrel(action.DefName)) + { + TryAddDefender(school, circle, action); + } + + LogConflictStart(school, circle); + return true; + } + + private static bool TryStartApology(School school, Person initiator, string nodeId, ActionDef action) + { + string? otherId = null; + foreach (var debt in school.ApologyDebts.Values) + { + if (!debt.FromId.Equals(initiator.Id, StringComparison.Ordinal)) + { + continue; + } + + if (!IsIdleInNode(school, debt.ToId, nodeId)) + { + continue; + } + + otherId = debt.ToId; + break; + } + + if (otherId is null) + { + return false; + } + + var members = new List { initiator.Id, otherId }; + members.Sort(StringComparer.Ordinal); + var circle = new ActiveTalkCircle + { + Id = $"apology-{initiator.Id}-{otherId}-{school.Clock.Time.Ticks}", + ActionId = action.DefName, + TopicId = action.DefName, + NodeId = nodeId, + Members = members, + RemainingMinutes = action.Minutes, + OpinionBaseline = CaptureBaselines(school, members), + }; + Register(school, circle, action); + return true; + } + + private static string? PickConflictTarget(School school, Person initiator, string nodeId) + { + var rules = school.Catalog!.BehaviorRules; + var day = DateOnly.FromDateTime(school.Clock.Time).DayNumber; + var remembered = Conflict.ResolveVictim( + initiator, + school.Roster!.People, + school.Catalog, + Seed.Mix(school.PeopleSeed, initiator.Id, day, Seed.ConflictSalt + 1)); + var idle = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false, studentsOnly: true) + .Where(candidate => candidate.IsIdle) + .Select(candidate => candidate.Id) + .ToArray(); + if (remembered is not null && idle.Contains(remembered, StringComparer.Ordinal)) + { + return remembered; + } + + foreach (var id in idle.OrderBy(value => value, StringComparer.Ordinal)) + { + var other = school.Roster.People.First(person => person.Id.Equals(id, StringComparison.Ordinal)); + if (Conflict.WantsQuarrel(OpinionStore.Get(initiator, id), rules, remembered, id) + || Conflict.WantsQuarrel(OpinionStore.Get(other, initiator.Id), rules, other.BullyVictimId, initiator.Id)) + { + return id; + } + } + + return null; + } + + private static bool TryJoinAsDefender(School school, ActiveTalkCircle circle, Person person, ActionDef action) + { + if (circle.Members.Contains(person.Id) || circle.Members.Count >= 3 || circle.VictimId is null) + { + return false; + } + + var opinion = OpinionStore.Get(person, circle.VictimId); + if (!Conflict.ShouldDefend(opinion, school.Catalog!.BehaviorRules)) + { + return false; + } + + circle.Members.Add(person.Id); + circle.Members.Sort(StringComparer.Ordinal); + school.TalkCircleByPerson[person.Id] = circle.Id; + foreach (var other in circle.Members) + { + if (other.Equals(person.Id, StringComparison.Ordinal)) + { + continue; + } + + CapturePair(school, circle.OpinionBaseline, person.Id, other); + } + + SetActivity(school, person.Id, action.DefName, circle.TopicId, circle.RemainingMinutes); + return true; + } + + private static void TryAddDefender(School school, ActiveTalkCircle circle, ActionDef action) + { + if (circle.VictimId is null || circle.Members.Count >= 3) + { + return; + } + + var except = new HashSet(circle.Members, StringComparer.Ordinal); + Person? defender = null; + school.World.Query( + in People, + (ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) => + { + if (defender is not null + || except.Contains(identity.Id) + || !roles.IsStudent + || presence.NodeId is null + || !presence.NodeId.Equals(circle.NodeId, StringComparison.Ordinal) + || activity.IsActive + || school.TalkCircleByPerson.ContainsKey(identity.Id)) + { + return; + } + + var candidateId = identity.Id; + var person = school.Roster!.People.FirstOrDefault(row => + row.Id.Equals(candidateId, StringComparison.Ordinal)); + if (person is null) + { + return; + } + + if (Conflict.ShouldDefend(OpinionStore.Get(person, circle.VictimId), school.Catalog!.BehaviorRules)) + { + defender = person; + } + }); + + if (defender is not null) + { + TryJoinAsDefender(school, circle, defender, action); + } + } + + private static ActiveTalkCircle? FindOpenConflict(School school, string nodeId, string actionId) + { + foreach (var circle in school.TalkCirclesById.Values) + { + if (circle.NodeId.Equals(nodeId, StringComparison.Ordinal) + && circle.ActionId.Equals(actionId, StringComparison.Ordinal) + && circle.RemainingMinutes > 0 + && TalkActions.IsQuarrel(circle.ActionId) + && circle.Members.Count < 3) + { + return circle; + } + } + + return null; + } + + private static Dictionary CaptureBaselines(School school, IReadOnlyList members) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var fromId in members) + { + foreach (var toId in members) + { + if (fromId.Equals(toId, StringComparison.Ordinal)) + { + continue; + } + + CapturePair(school, map, fromId, toId); + } + } + + return map; + } + + private static void CapturePair(School school, Dictionary map, string fromId, string toId) + { + var from = school.Roster!.People.First(person => person.Id.Equals(fromId, StringComparison.Ordinal)); + map[PairKey(fromId, toId)] = OpinionStore.Get(from, toId) ?? 0; + } + + private static string PairKey(string fromId, string toId) => string.Concat(fromId, ">", toId); + + private static string DebtKey(string fromId, string toId) => string.Concat(fromId, "\t", toId); + + private static bool IsIdleInNode(School school, string personId, string nodeId) + { + var idle = false; + school.World.Query( + in People, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) => + { + if (!identity.Id.Equals(personId, StringComparison.Ordinal)) + { + return; + } + + idle = presence.NodeId is not null + && presence.NodeId.Equals(nodeId, StringComparison.Ordinal) + && !activity.IsActive + && !school.TalkCircleByPerson.ContainsKey(personId); + }); + return idle; + } + + private static void LogConflictStart(School school, ActiveTalkCircle circle) + { + var type = TalkActions.IsFight(circle.ActionId) ? PersonLogTypes.Fought : PersonLogTypes.Quarreled; + foreach (var member in circle.Members) + { + school.AppendDayLog(new PersonLogEvent(member, school.Clock.Time, type, circle.ActionId)); + } + } + + private static void FinishConflict(School school, ActiveTalkCircle circle, bool reprimanded) + { + var catalog = school.Catalog!; + var rules = catalog.BehaviorRules; + var people = circle.Members + .Select(id => school.Roster!.People.First(person => person.Id.Equals(id, StringComparison.Ordinal))) + .ToArray(); + if (!catalog.Actions.TryGetValue(circle.ActionId, out var action)) + { + action = catalog.Actions[TalkActions.Quarrel]; + } + + if (TalkActions.IsApologize(circle.ActionId)) + { + ApplyApology(school, people, rules); + } + else + { + var delta = TalkActions.IsFight(circle.ActionId) + ? Conflict.FightOpinionDelta(rules) + : Conflict.QuarrelOpinionDelta(rules); + ApplyClash(school, circle, people, action, delta); + if (reprimanded) + { + foreach (var person in people) + { + school.AppendDayLog(new PersonLogEvent( + person.Id, + school.Clock.Time, + PersonLogTypes.Reprimanded, + circle.ActionId)); + } + } + } + + foreach (var member in circle.Members) + { + school.TalkCircleByPerson.Remove(member); + ClearActivity(school, member); + } + + school.TalkCirclesById.Remove(circle.Id); + } + + private static void ApplyClash( + School school, + ActiveTalkCircle circle, + IReadOnlyList people, + ActionDef action, + int delta) + { + var catalog = school.Catalog!; + var rules = catalog.BehaviorRules; + foreach (var person in people) + { + if (!string.IsNullOrWhiteSpace(action.Need) + && catalog.Needs.TryGetValue(action.Need, out var need) + && !need.DefName.Equals("Health", StringComparison.OrdinalIgnoreCase)) + { + var current = NeedOf(school, person.Id, action.Need); + if (float.IsNaN(current)) + { + current = need.Min; + } + + MutateNeed(school, person.Id, action.Need, ActionStepper.ApplyNeedGain(current, action, need)); + } + + foreach (var other in people) + { + if (other.Id.Equals(person.Id, StringComparison.Ordinal)) + { + continue; + } + + var before = circle.OpinionBaseline.GetValueOrDefault( + PairKey(person.Id, other.Id), + 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)) + { + shift = Math.Max(2, -delta / 4); + } + + OpinionStore.Set(person, other.Id, now + shift); + var after = OpinionStore.Get(person, other.Id) ?? 0; + var lost = Math.Max(0, before - after); + if (lost > 0) + { + school.ApologyDebts[DebtKey(person.Id, other.Id)] = new ApologyDebt(person.Id, other.Id, before, lost); + } + } + } + } + + private static void ApplyApology(School school, IReadOnlyList people, BehaviorDef? rules) + { + foreach (var person in people) + { + foreach (var other in people) + { + if (other.Id.Equals(person.Id, StringComparison.Ordinal)) + { + continue; + } + + var key = DebtKey(person.Id, other.Id); + if (!school.ApologyDebts.TryGetValue(key, out var debt)) + { + continue; + } + + var current = OpinionStore.Get(person, other.Id) ?? 0; + OpinionStore.Set(person, other.Id, Conflict.ApologyOpinion(current, debt.Baseline, debt.Lost, rules)); + school.ApologyDebts.Remove(key); + } + + school.AppendDayLog(new PersonLogEvent( + person.Id, + school.Clock.Time, + PersonLogTypes.Apologized, + TalkActions.Apologize)); + } + } + private static bool RoleFits(ActionDef action, Person person) { if (action.Roles.Count == 0) diff --git a/tests/HSchool.Ai.Tests/ConflictTests.cs b/tests/HSchool.Ai.Tests/ConflictTests.cs new file mode 100644 index 0000000..bccbadc --- /dev/null +++ b/tests/HSchool.Ai.Tests/ConflictTests.cs @@ -0,0 +1,101 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Ai.Tests; + +public class ConflictTests +{ + [Fact] + public void Enemies_HaveHigherQuarrelChanceThanFriends() + { + var (catalog, _) = Fixtures.Vanilla(); + var rules = catalog.BehaviorRules!; + var enemy = Conflict.QuarrelChance(-55, [], catalog, rules, null, "b"); + var friend = Conflict.QuarrelChance(55, [], catalog, rules, null, "b"); + Assert.True(enemy > friend); + Assert.Equal(0f, friend); + Assert.True(enemy > 0f); + } + + [Fact] + public void Bully_KeepsSameVictimTwoDays_WhileTheyStayOnRoster() + { + var (catalog, _) = Fixtures.Vanilla(); + Assert.True(catalog.Traits["Bully"].RemembersVictim); + Assert.False(catalog.Traits["HotTempered"].RemembersVictim); + + var bully = Person("bully", ["Bully"]); + var first = Person("p-a", []); + var second = Person("p-b", []); + var roster = new[] { bully, first, second }; + + var day1 = Conflict.ResolveVictim(bully, roster, catalog, seed: 11); + var day2 = Conflict.ResolveVictim(bully, roster, catalog, seed: 99); + + Assert.NotNull(day1); + Assert.Equal(day1, day2); + Assert.Equal(day1, bully.BullyVictimId); + } + + [Fact] + public void Apology_RaisesOpinion_ButNotAbovePreQuarrel() + { + var (catalog, _) = Fixtures.Vanilla(); + var rules = catalog.BehaviorRules!; + const int baseline = 10; + var lost = -Conflict.QuarrelOpinionDelta(rules); + var afterQuarrel = baseline - lost; + var restored = Conflict.ApologyOpinion(afterQuarrel, baseline, lost, rules); + + Assert.True(lost > 3); + Assert.True(restored > afterQuarrel); + Assert.True(restored <= baseline); + } + + [Fact] + public void Catalog_HasNoHealthNeed() + { + var (catalog, _) = Fixtures.Vanilla(); + Assert.False(Conflict.HasHealthNeed(catalog)); + Assert.False(catalog.Needs.ContainsKey("Health")); + } + + [Fact] + public void Fight_DoesNotBlockLesson_QuarrelDoes() + { + Assert.True(TalkCircles.BlocksOnLesson(TalkActions.Quarrel)); + Assert.True(TalkCircles.BlocksOnLesson(TalkActions.QuarrelYard)); + Assert.False(TalkCircles.BlocksOnLesson(TalkActions.Fight)); + Assert.False(TalkCircles.BlocksOnLesson(TalkActions.FightGym)); + } + + private static Person Person(string id, string[] traits) + { + 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(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = traits, + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } +} diff --git a/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs b/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs new file mode 100644 index 0000000..4a73c3d --- /dev/null +++ b/tests/HSchool.Simulation.Tests/QuarrelFightTests.cs @@ -0,0 +1,349 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Simulation; + +namespace HSchool.Simulation.Tests; + +public class QuarrelFightTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SaturdayMorning = new(2012, 4, 7, 10, 0, 0, DateTimeKind.Utc); + + [Fact] + public void EnemiesInYard_QuarrelMoreOftenThanFriends() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + + OpinionStore.Set(first, second.Id, -55); + OpinionStore.Set(second, first.Id, -55); + Assert.True(school.TryStartAction(first.Id, TalkActions.QuarrelYard)); + TalkCircleSystem.Interrupt(school, first.Id); + + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + OpinionStore.Set(first, second.Id, 55); + OpinionStore.Set(second, first.Id, 55); + Assert.False(school.TryStartAction(first.Id, TalkActions.QuarrelYard)); + } + } + + [Fact] + public void Bully_TwoDaysInARow_AimsAtTheSamePerson_IfStillInSchool() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + GiveTrait(school, first.Id, "Bully"); + var bully = school.Roster!.People.First(person => person.Id == first.Id); + var roster = school.Roster.People; + var day1 = Conflict.ResolveVictim(bully, roster, school.Catalog!, seed: 3); + var day2 = Conflict.ResolveVictim(bully, roster, school.Catalog!, seed: 40); + Assert.NotNull(day1); + Assert.Equal(day1, day2); + Assert.Contains(roster, person => person.Id == day1); + } + } + + [Fact] + public void Apology_RaisesOpinion_ButNotAbovePreQuarrel() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + const int baseline = -50; + OpinionStore.Set(first, second.Id, baseline); + OpinionStore.Set(second, first.Id, baseline); + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + + Assert.True(school.TryStartAction(first.Id, TalkActions.QuarrelYard)); + TickWhile(school, personId => ActivityOf(school, personId) == TalkActions.QuarrelYard, first.Id, second.Id); + + if (!school.DayLog.Any(row => row.Type == PersonLogTypes.Apologized)) + { + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + Assert.True(school.TryStartAction(first.Id, TalkActions.Apologize)); + TickWhile(school, personId => ActivityOf(school, personId) == TalkActions.Apologize, first.Id, second.Id); + } + + var after = OpinionStore.Get(first, second.Id) ?? 0; + Assert.True(after <= baseline); + Assert.True(after > baseline + Conflict.QuarrelOpinionDelta(school.Catalog!.BehaviorRules)); + Assert.Contains(school.DayLog, row => row.Type == PersonLogTypes.Quarreled); + Assert.Contains(school.DayLog, row => row.Type == PersonLogTypes.Apologized); + } + } + + [Fact] + public void Fight_DoesNotChangeHealthNeed_BecauseThereIsNone() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + Assert.False(Conflict.HasHealthNeed(school.Catalog!)); + OpinionStore.Set(first, second.Id, -55); + OpinionStore.Set(second, first.Id, -55); + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + var before = NeedsOf(school, first.Id); + + Assert.True(school.TryStartAction(first.Id, TalkActions.Fight)); + TickWhile(school, personId => TalkActions.IsFight(ActivityOf(school, personId)), first.Id, second.Id); + + var after = NeedsOf(school, first.Id); + Assert.False(after.ContainsKey("Health")); + Assert.Equal(before.Keys.OrderBy(key => key, StringComparer.Ordinal), after.Keys.OrderBy(key => key, StringComparer.Ordinal)); + Assert.Contains(school.DayLog, row => row.Type == PersonLogTypes.Fought); + } + } + + [Fact] + public void StaffInNode_BreaksFight_AndWritesReprimand() + { + var (school, first, second, staffId) = TwoPupilsAndStaffOnBreak(); + using (school) + { + OpinionStore.Set(first, second.Id, -55); + OpinionStore.Set(second, first.Id, -55); + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + PlaceAt(school, staffId, "yard"); + + Assert.True(school.TryStartAction(first.Id, TalkActions.Fight)); + Assert.Equal(TalkActions.Fight, ActivityOf(school, first.Id)); + PlaceAt(school, staffId, "yard"); + TalkCircleSystem.Apply(school, 1d); + + Assert.Contains( + school.DayLog, + row => row.Type == PersonLogTypes.Reprimanded + && (row.PersonId == first.Id || row.PersonId == second.Id)); + Assert.False(IsConflictActivity(school, first.Id)); + } + } + + [Fact] + public void SkipEmpty_DoesNotLeaveAFightInState() + { + var (catalog, map) = Vanilla(); + var school = OpenSchool(catalog, map, seed: 42, SaturdayMorning, advanceToBreak: false); + using (school) + { + var schoolClass = school.Roster!.Classes.First(row => row.RoomId == "classroom-101"); + var pupils = schoolClass.PupilIds + .Select(id => school.Roster.People.First(person => person.Id == id)) + .Take(2) + .ToArray(); + OpinionStore.Set(pupils[0], pupils[1].Id, -55); + OpinionStore.Set(pupils[1], pupils[0].Id, -55); + PlaceAt(school, pupils[0].Id, "yard"); + PlaceAt(school, pupils[1].Id, "yard"); + Assert.True(school.TryStartAction(pupils[0].Id, TalkActions.Fight)); + Assert.True(IsConflictActivity(school, pupils[0].Id)); + + foreach (var person in school.Roster.People) + { + PlaceAt(school, person.Id, nodeId: null); + } + + Assert.True(school.IsCampusEmpty()); + Assert.True(school.TrySkipEmpty().Succeeded); + Assert.Empty(school.TalkCirclesById); + Assert.DoesNotContain( + school.CapturePresence(), + row => TalkActions.IsFight(row.ActionId)); + } + } + + [Fact] + public void Defender_JoinsQuarrelOnVictimsSide() + { + 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)); + Assert.Equal(TalkActions.Quarrel, ActivityOf(school, bully.Id)); + Assert.Equal(TalkActions.Quarrel, ActivityOf(school, victim.Id)); + Assert.Equal(TalkActions.Quarrel, ActivityOf(school, defender.Id)); + } + } + + private static (School School, Person First, Person Second) TwoPupilsOnBreak() + { + var (catalog, map) = Vanilla(); + var school = OpenSchool(catalog, map, seed: 42, TuesdayMorning, advanceToBreak: true); + var schoolClass = school.Roster!.Classes.First(row => row.RoomId == "classroom-101"); + var pupils = schoolClass.PupilIds + .Select(id => school.Roster.People.First(person => person.Id == id)) + .Take(2) + .ToArray(); + return (school, pupils[0], pupils[1]); + } + + private static (School School, IReadOnlyList Pupils) ThreePupilsOnBreak() + { + var (catalog, map) = Vanilla(); + var school = OpenSchool(catalog, map, seed: 43, TuesdayMorning, advanceToBreak: true); + var schoolClass = school.Roster!.Classes.First(row => row.RoomId == "classroom-101"); + var pupils = schoolClass.PupilIds + .Select(id => school.Roster.People.First(person => person.Id == id)) + .Take(3) + .ToArray(); + Assert.Equal(3, pupils.Length); + return (school, pupils); + } + + private static (School School, Person First, Person Second, string StaffId) TwoPupilsAndStaffOnBreak() + { + var (school, first, second) = TwoPupilsOnBreak(); + const float cap = 100_000f; + var roster = school.Roster!; + var pool = school.Applicants!; + var hired = Staffing.Hire( + school.Catalog!, + school.Map!, + roster, + pool, + pool.Applicants[0].Person.Id, + Staffing.TeacherPosition, + cap); + Assert.Equal(StaffingError.None, hired.Error); + school.ApplyStaffing(hired.Roster, hired.Pool); + first = school.Roster!.People.First(person => person.Id == first.Id); + second = school.Roster.People.First(person => person.Id == second.Id); + var staffId = school.Roster.People.First(person => person.IsStaff).Id; + return (school, first, second, staffId); + } + + private static School OpenSchool(DefCatalog catalog, MapLayout map, int seed, DateTime start, bool advanceToBreak) + { + var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start); + var schoolClass = roster.Classes.First(row => row.RoomId == "classroom-101"); + var school = School.Create(seed, "Conflict", start, catalog, map); + school.InstallPeople(roster, seed, "Russia", pool); + school.SetTimetable(new HSchool.Schedule.Timetable( + [ + new HSchool.Schedule.LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), + ], + [])); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + if (advanceToBreak) + { + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc)); + } + + return school; + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static void PlaceAt(School school, string personId, string? nodeId) + { + TalkCircleSystem.Interrupt(school, personId); + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + presence = nodeId is null ? Presence.OffCampus : new Presence(nodeId, 0f, nodeId, false, []); + activity = PersonActivity.Idle; + intent = Intent.None; + } + }); + } + + private static void TickWhile(School school, Func stillGoing, params string[] personIds) + { + for (var i = 0; i < 12 && personIds.Any(stillGoing); i++) + { + school.Tick(0.2d, 5d); + } + } + + private static void GiveTrait(School school, string personId, string trait) + { + var people = school.Roster!.People.Select(person => + person.Id.Equals(personId, StringComparison.Ordinal) + ? person with { Traits = person.Traits.Append(trait).Distinct(StringComparer.Ordinal).ToArray() } + : person) + .ToArray(); + var roster = school.Roster with { People = people }; + school.InstallPeople(roster, school.PeopleSeed, school.CountryId, school.Applicants); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc)); + } + + private static bool IsConflictActivity(School school, string personId) + { + var action = ActivityOf(school, personId); + return TalkActions.IsConflict(action); + } + + private static string? ActivityOf(School school, string personId) => + school.CapturePresence().Single(row => row.PersonId == personId).ActionId; + + private static Dictionary NeedsOf(School school, string personId) + { + var values = new Dictionary(StringComparer.Ordinal); + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + foreach (var (key, value) in needs.Values) + { + values[key] = value; + } + } + }); + return values; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + documents.Add(new ContentDocument( + CatalogLoader.CorePackId, + Path.GetRelativePath(root, path).Replace('\\', '/'), + File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}