diff --git a/docs/phases/09-social/44-quarrel-fight.md b/docs/phases/09-social/44-quarrel-fight.md
index d771f99..b259595 100644
--- a/docs/phases/09-social/44-quarrel-fight.md
+++ b/docs/phases/09-social/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/docs/phases/09-social/README.md b/docs/phases/09-social/README.md
index f42c6cc..830d74c 100644
--- a/docs/phases/09-social/README.md
+++ b/docs/phases/09-social/README.md
@@ -26,7 +26,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
-| [44. Ссора и драка](44-quarrel-fight.md) | 🔄 | Жертва задиры, заступник, извинение, двор/физкультура |
+| [44. Ссора и драка](44-quarrel-fight.md) | ✅ | Жертва задиры, заступник, извинение, двор/физкультура |
| [45. Кружок в присутствии](45-presence-talk.md) | ✅ | Id участников и темы в кадре, протокол +1 |
44 и 45 стоят на 42, можно параллельно.
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 2cc4392..b003321 100644
--- a/src/HSchool.Content/PeopleDefValidator.cs
+++ b/src/HSchool.Content/PeopleDefValidator.cs
@@ -281,6 +281,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)
@@ -627,6 +637,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 3d64637..61cd49a 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -227,6 +227,20 @@ public sealed class TraitDef : Def
/// higher (jealous). Ignored without affinity rules.
///
public int AffinityBreakOffset { get; init; }
+
+ ///
+ /// 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;
}
///
@@ -528,6 +542,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 46f3c9b..00c0ee2 100644
--- a/src/HSchool.People/Roster.cs
+++ b/src/HSchool.People/Roster.cs
@@ -63,6 +63,12 @@ public sealed record Person
/// Crushes and the one pair. Null without an orientation pack. Mutable overlay.
public PersonBonds? Bonds { get; set; }
+ ///
+ /// 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 85d21c7..41d6186 100644
--- a/src/HSchool.People/Seed.cs
+++ b/src/HSchool.People/Seed.cs
@@ -19,6 +19,7 @@ public static class Seed
public const int ApparelSalt = 11;
public const int WhisperSalt = 12;
public const int OrientationSalt = 13;
+ public const int ConflictSalt = 14;
/// 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 17e4c29..852b524 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)
{
var emptyKey = TypeToLocaleKey(Type);
diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs
index 8d215ec..44541cc 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 447de1d..7d43461 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();
@@ -277,6 +279,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();
@@ -513,3 +516,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);
+ }
+}