Files
h-school/src/HSchool.Simulation/TalkCircleSystem.cs
T
Leonid Pershin fdcaed0f74 Remember recent quarrels, fights and reprimands on the person.
Short offense list for the pupil card and save, capped by BehaviorDef — not a discipline score.
2026-08-21 09:37:12 +03:00

1247 lines
42 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
internal sealed class ActiveTalkCircle
{
public required string Id { get; init; }
public required string ActionId { get; init; }
public required string TopicId { get; set; }
public required string NodeId { get; init; }
public required List<string> Members { get; init; }
public float RemainingMinutes { get; set; }
/// <summary>Set once a teacher in the room has rolled to notice this whisper.</summary>
public bool CatchChecked { get; set; }
/// <summary>The person a quarrel or fight is aimed at. Null on ordinary talk.</summary>
public string? VictimId { get; init; }
/// <summary>Opinion before this clash, keyed <c>fromId&gt;toId</c>. Used to cap apologies.</summary>
public Dictionary<string, int> OpinionBaseline { get; init; } = new(StringComparer.Ordinal);
}
/// <summary>Forms 24 person talk circles, applies outcomes, writes topic log lines.</summary>
internal static class TalkCircleSystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, PersonSkills, PersonNeeds, Presence, PersonActivity>();
public static bool IsInCircle(School school, string personId) =>
school.TalkCircleByPerson.ContainsKey(personId);
/// <summary>
/// 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.
/// </summary>
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;
}
});
}
/// <summary>Ends an active circle when duty overrides break talk (bell rang).</summary>
public static void Interrupt(School school, string personId)
{
if (!school.TalkCircleByPerson.TryGetValue(personId, out var circleId)
|| !school.TalkCirclesById.TryGetValue(circleId, out var circle))
{
return;
}
if (TalkActions.IsConflict(circle.ActionId))
{
FinishConflict(school, circle, reprimanded: false);
return;
}
Finish(school, circle);
}
public static bool TryStart(School school, string personId, string actionId)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return false;
}
if (!TalkActions.IsTalk(actionId) || !school.Catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract)
{
return false;
}
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))
{
return false;
}
string? nodeId = null;
var busy = false;
school.World.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
{
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
nodeId = presence.NodeId;
busy = activity.IsActive;
});
if (nodeId is null || busy)
{
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))
{
return false;
}
if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal))
{
return TryStartPhone(school, person, nodeId, action);
}
var existing = FindOpenCircle(school, nodeId, actionId, person);
if (existing is not null && JoinCircle(school, existing, personId, action))
{
return true;
}
return TryStartGroup(school, person, nodeId, action);
}
public static IReadOnlyList<string> Apply(School school, double gameMinutes)
{
if (gameMinutes <= 0 || school.Catalog is null || school.Roster is null)
{
return [];
}
var minutes = (float)gameMinutes;
var completed = new List<string>();
foreach (var circle in school.TalkCirclesById.Values.ToArray())
{
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;
}
if (circle.RemainingMinutes > 0)
{
continue;
}
if (TalkActions.IsConflict(circle.ActionId))
{
FinishConflict(school, circle, reprimanded: false);
}
else
{
Finish(school, circle);
}
completed.AddRange(circle.Members);
}
completed.Sort(StringComparer.Ordinal);
return completed;
}
private static bool TryStartPhone(School school, Person initiator, string nodeId, ActionDef action)
{
if (!TalkCircles.HasPhone(initiator.Items))
{
return false;
}
var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: true);
var ranked = TalkCircles.RankInvitees(initiator, candidates, school.Catalog!.BehaviorRules);
if (ranked.Count == 0)
{
return false;
}
var partnerId = ranked[0];
var partner = school.Roster!.People.First(row => row.Id.Equals(partnerId, StringComparison.Ordinal));
if (!TalkCircles.HasPhone(partner.Items))
{
return false;
}
var topicId = TalkCircles.PickTopic(
school.Catalog!,
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20),
speechPolicy: school.SpeechRules.For(initiator.IsStudent));
if (topicId is null)
{
return false;
}
var circle = new ActiveTalkCircle
{
Id = $"phone-{initiator.Id}-{partnerId}",
ActionId = action.DefName,
TopicId = topicId,
NodeId = nodeId,
Members = [initiator.Id, partnerId],
RemainingMinutes = action.Minutes,
};
Register(school, circle, action);
return true;
}
private static bool TryStartGroup(School school, Person initiator, string nodeId, ActionDef action)
{
var rules = school.Catalog!.BehaviorRules;
var max = TalkCircles.MaxSize(action.DefName, rules, initiator.Traits, school.Catalog);
var studentsOnly = TalkActions.IsWhisper(action.DefName) || TalkActions.IsTeacherTalk(action.DefName);
var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false, studentsOnly);
var ranked = TalkCircles.RankInvitees(initiator, candidates, rules);
var members = new List<string> { initiator.Id };
foreach (var id in ranked)
{
if (members.Count >= max)
{
break;
}
members.Add(id);
}
var min = TalkActions.IsTeacherTalk(action.DefName) ? 3 : rules?.TalkCircleMin ?? 2;
if (members.Count < min)
{
return false;
}
var topicId = TalkCircles.PickTopic(
school.Catalog,
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21),
whisperOnLesson: TalkActions.IsWhisper(action.DefName),
speechPolicy: school.SpeechRules.For(initiator.IsStudent));
if (TalkActions.IsTeacherTalk(action.DefName))
{
topicId = TalkCircles.PickTeacherTopic(
school.Catalog,
rules,
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt));
}
if (topicId is null)
{
return false;
}
var circle = new ActiveTalkCircle
{
Id = $"talk-{initiator.Id}-{nodeId}-{school.Clock.Time.Ticks}",
ActionId = action.DefName,
TopicId = topicId,
NodeId = nodeId,
Members = members,
RemainingMinutes = action.Minutes,
};
Register(school, circle, action);
return true;
}
private static bool JoinCircle(School school, ActiveTalkCircle circle, string personId, ActionDef action)
{
var rules = school.Catalog!.BehaviorRules;
var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal));
if ((TalkActions.IsWhisper(action.DefName) || TalkActions.IsTeacherTalk(action.DefName)) && !person.IsStudent)
{
return false;
}
var max = TalkCircles.MaxSize(action.DefName, rules, person.Traits, school.Catalog);
if (circle.Members.Count >= max || circle.Members.Contains(personId))
{
return false;
}
var sitting = circle.Members
.Select(id => school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal)))
.ToArray();
if (!TalkCircles.JoinerFits(person, sitting, rules))
{
return false;
}
circle.Members.Add(personId);
circle.Members.Sort(StringComparer.Ordinal);
school.TalkCircleByPerson[personId] = circle.Id;
SetActivity(school, personId, action.DefName, circle.TopicId, circle.RemainingMinutes);
return true;
}
private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId, Person joiner)
{
var rules = school.Catalog!.BehaviorRules;
var max = TalkCircles.MaxSize(actionId, rules, [], school.Catalog);
foreach (var circle in school.TalkCirclesById.Values)
{
if (!circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
|| !circle.ActionId.Equals(actionId, StringComparison.Ordinal)
|| circle.RemainingMinutes <= 0
|| circle.Members.Count >= max)
{
continue;
}
var sitting = circle.Members
.Select(id => school.Roster!.People.First(row => row.Id.Equals(id, StringComparison.Ordinal)))
.ToArray();
if (TalkCircles.JoinerFits(joiner, sitting, rules))
{
return circle;
}
}
return null;
}
private static IReadOnlyList<TalkCircles.Candidate> GatherCandidates(
School school,
string exceptId,
string nodeId,
bool requirePhone,
bool studentsOnly = false)
{
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var list = new List<TalkCircles.Candidate>();
school.World.Query(
in People,
(ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) =>
{
if (identity.Id.Equals(exceptId, StringComparison.Ordinal)
|| presence.NodeId is null
|| !presence.NodeId.Equals(nodeId, StringComparison.Ordinal))
{
return;
}
if (studentsOnly && !roles.IsStudent)
{
return;
}
if (!roster.TryGetValue(identity.Id, out var person))
{
return;
}
if (requirePhone && !TalkCircles.HasPhone(person.Items))
{
return;
}
list.Add(new TalkCircles.Candidate(
identity.Id,
roles.ClassId,
!activity.IsActive && !school.TalkCircleByPerson.ContainsKey(identity.Id),
school.TalkCircleByPerson.ContainsKey(identity.Id),
TalkCircles.HasPhone(person.Items)));
});
return list.Where(candidate => candidate.IsIdle).ToArray();
}
private static void Register(School school, ActiveTalkCircle circle, ActionDef action)
{
school.TalkCirclesById[circle.Id] = circle;
foreach (var member in circle.Members)
{
school.TalkCircleByPerson[member] = circle.Id;
SetActivity(school, member, action.DefName, circle.TopicId, circle.RemainingMinutes);
if (TalkActions.IsWhisper(action.DefName))
{
school.AppendDayLog(new PersonLogEvent(
member,
school.Clock.Time,
PersonLogTypes.Whispered,
circle.TopicId));
}
}
}
private static void SetActivity(School school, string personId, string actionId, string topicId, float remaining)
{
school.World.Query(
in People,
(ref PersonIdentity identity, ref PersonActivity activity) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
activity = new PersonActivity(actionId, topicId, remaining);
}
});
}
private static void SyncActivity(School school, ActiveTalkCircle circle)
{
foreach (var member in circle.Members)
{
SetActivity(school, member, circle.ActionId, circle.TopicId, Math.Max(0f, circle.RemainingMinutes));
}
}
private static void Finish(School school, ActiveTalkCircle circle)
{
var catalog = school.Catalog!;
var rules = catalog.BehaviorRules;
var roster = school.Roster!;
var people = circle.Members
.Select(id => roster.People.First(person => person.Id.Equals(id, StringComparison.Ordinal)))
.ToArray();
if (!catalog.Topics.TryGetValue(circle.TopicId, out var topic))
{
topic = catalog.Topics.Values.First(def => !def.Abstract);
}
if (!catalog.Actions.TryGetValue(circle.ActionId, out var action))
{
action = catalog.Actions[TalkActions.Chat];
}
var hours = action.Minutes / 60f;
var sharedLanguage = TalkCircles.SharedLanguage(catalog, people, rules) is not null;
var language = TalkCircles.SharedLanguage(catalog, people, rules);
foreach (var person in people)
{
if (!string.IsNullOrWhiteSpace(action.Need)
&& catalog.Needs.TryGetValue(action.Need, out var need))
{
var current = NeedOf(school, person.Id, action.Need);
if (float.IsNaN(current))
{
current = need.Min;
}
var next = ActionStepper.ApplyNeedGain(current, action, need);
MutateNeed(school, person.Id, action.Need, next);
}
if (catalog.Skills.TryGetValue("Communication", out var communicationSkill))
{
var level = SkillOf(school, person.Id, "Communication");
var next = TalkCircles.CommunicationGain(level, communicationSkill, hours, rules);
MutateSkill(school, person.Id, "Communication", next);
}
if (language is not null
&& catalog.Skills.TryGetValue(language, out var languageSkill)
&& SkillOf(school, person.Id, language) < languageSkill.Range.Max)
{
var level = SkillOf(school, person.Id, language);
var next = TalkCircles.LanguageGain(level, languageSkill, hours, rules);
MutateSkill(school, person.Id, language, next);
}
foreach (var other in people)
{
if (other.Id.Equals(person.Id, StringComparison.Ordinal))
{
continue;
}
var communication = SkillOf(school, person.Id, "Communication");
var appearance = AppearanceOf(school, other);
var delta = TalkCircles.OpinionDelta(
person,
other,
topic,
communication,
sharedLanguage,
circle.ActionId,
appearance,
rules,
catalog);
if (person.IsStudent && other.IsStaff)
{
delta = TalkCircles.ScaleByPedagogy(delta, SkillOf(school, other.Id, "Pedagogy"), rules);
}
if (delta == 0)
{
continue;
}
var current = OpinionStore.Get(person, other.Id) ?? 0;
OpinionStore.Set(person, other.Id, current + delta);
}
school.AppendDayLog(new PersonLogEvent(
person.Id,
school.Clock.Time,
PersonLogTypes.TalkEnded,
circle.TopicId));
}
foreach (var member in circle.Members)
{
school.TalkCircleByPerson.Remove(member);
ClearActivity(school, member);
}
school.TalkCirclesById.Remove(circle.Id);
school.RosterTalkDirty = true;
}
private static bool TryCatchWhisper(School school, ActiveTalkCircle circle)
{
if (circle.CatchChecked)
{
return false;
}
var teacherId = StaffStandingIn(school, circle.NodeId);
if (teacherId is null)
{
return false;
}
circle.CatchChecked = true;
var rules = school.Catalog!.BehaviorRules;
var traits = new List<string>();
foreach (var memberId in circle.Members)
{
var member = school.Roster!.People.FirstOrDefault(person => person.Id.Equals(memberId, StringComparison.Ordinal));
if (member is not null)
{
traits.AddRange(member.Traits);
}
}
var roll = TalkCircles.Roll01(
Seed.Mix(school.PeopleSeed, circle.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt));
var multiplier = TalkCircles.WhisperCatchMultiplier(traits, school.Catalog);
if (!TalkCircles.WhisperCaught(
roll,
rules?.WhisperCatchChance ?? 0.4f,
rules?.WhisperCatchMax ?? 0.85f,
multiplier))
{
return false;
}
ConvertToReprimand(school, circle, teacherId);
return true;
}
private static void ConvertToReprimand(School school, ActiveTalkCircle circle, string teacherId)
{
var pupils = circle.Members.ToArray();
foreach (var member in pupils)
{
school.AppendDayLog(new PersonLogEvent(
member,
school.Clock.Time,
PersonLogTypes.TeacherInterrupted,
circle.TopicId));
school.TalkCircleByPerson.Remove(member);
ClearActivity(school, member);
}
school.TalkCirclesById.Remove(circle.Id);
if (!school.Catalog!.Actions.TryGetValue(TalkActions.TeacherTalk, out var action))
{
return;
}
var members = new List<string> { teacherId };
var max = TalkCircles.MaxSize(TalkActions.TeacherTalk, school.Catalog.BehaviorRules, [], school.Catalog);
foreach (var id in pupils.OrderBy(value => value, StringComparer.Ordinal))
{
if (members.Count >= max)
{
break;
}
if (!members.Contains(id, StringComparer.Ordinal))
{
members.Add(id);
}
}
if (members.Count < 3)
{
return;
}
var reprimand = new ActiveTalkCircle
{
Id = $"reprimand-{teacherId}-{circle.NodeId}-{school.Clock.Time.Ticks}",
ActionId = TalkActions.TeacherTalk,
TopicId = TeacherTopics.Discipline,
NodeId = circle.NodeId,
Members = members,
RemainingMinutes = action.Minutes,
CatchChecked = true,
};
Register(school, reprimand, action);
}
private static string? StaffStandingIn(School school, string nodeId)
{
string? found = null;
school.World.Query(
in People,
(ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) =>
{
if (found is not null
|| !roles.IsStaff
|| presence.NodeId is null
|| !presence.NodeId.Equals(nodeId, StringComparison.Ordinal)
|| presence.Path.Length > 0
|| presence.RemainingMinutes > 0)
{
return;
}
found = identity.Id;
});
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<string> { 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<string> { 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 previousVictim = initiator.BullyVictimId;
var remembered = Conflict.ResolveVictim(
initiator,
school.Roster!.People,
school.Catalog,
Seed.Mix(school.PeopleSeed, initiator.Id, day, Seed.ConflictSalt + 1));
if (!string.Equals(previousVictim, initiator.BullyVictimId, StringComparison.Ordinal))
{
school.RosterTalkDirty = true;
}
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<string>(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<string, int> CaptureBaselines(School school, IReadOnlyList<string> members)
{
var map = new Dictionary<string, int>(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<string, int> 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);
RecordConflictOffenses(school, circle, people, rules, reprimanded);
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);
school.RosterTalkDirty = true;
}
private static void RecordConflictOffenses(
School school,
ActiveTalkCircle circle,
IReadOnlyList<Person> people,
BehaviorDef? rules,
bool reprimanded)
{
if (rules is null)
{
return;
}
var kind = TalkActions.IsFight(circle.ActionId) ? OffenseKinds.Fight : OffenseKinds.Quarrel;
var time = school.Clock.Time;
foreach (var person in people)
{
var other = OffenseMemory.OtherParticipant(person.Id, circle.VictimId, circle.Members);
OffenseMemory.Record(person, kind, time, other, rules);
if (reprimanded)
{
OffenseMemory.Record(person, OffenseKinds.Reprimand, time, other, rules);
}
}
}
private static void ApplyClash(
School school,
ActiveTalkCircle circle,
IReadOnlyList<Person> 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 (AllyOfVictim(circle, person.Id, rules) && AllyOfVictim(circle, other.Id, rules))
{
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);
}
}
}
}
/// <summary>
/// Victim and anyone who joined on their side. Clash penalty is for opposite sides; allies
/// get a small plus instead — otherwise the victim's view of the defender falls like a fight.
/// </summary>
private static bool AllyOfVictim(ActiveTalkCircle circle, string personId, BehaviorDef? rules)
{
if (circle.VictimId is null)
{
return false;
}
if (personId.Equals(circle.VictimId, StringComparison.Ordinal))
{
return true;
}
return circle.OpinionBaseline.TryGetValue(PairKey(personId, circle.VictimId), out var viewOfVictim)
&& Conflict.ShouldDefend(viewOfVictim, rules);
}
private static void ApplyApology(School school, IReadOnlyList<Person> 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)
{
return true;
}
foreach (var role in action.Roles)
{
if (role.Equals(HSchool.Content.PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && person.IsStudent)
{
return true;
}
if (role.Equals(HSchool.Content.PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && person.IsStaff)
{
return true;
}
if (role.Equals(HSchool.Content.PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && person.IsParent)
{
return true;
}
}
return false;
}
private static ApparelIssue AppearanceOf(School school, Person person)
{
var schoolClass = person.ClassId is { } classId
? school.Roster!.Classes.FirstOrDefault(row => row.Id.Equals(classId, StringComparison.Ordinal))
: null;
var mode = ApparelPresence.ModeAt(school, person, schoolClass);
return ApparelDresser.CurrentIssues(school, person, mode);
}
private static float NeedOf(School school, string personId, string need)
{
var value = float.NaN;
school.World.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
value = needs.Values.GetValueOrDefault(need, float.NaN);
}
});
return value;
}
private static float SkillOf(School school, string personId, string skill)
{
var value = 0f;
school.World.Query(in People, (ref PersonIdentity identity, ref PersonSkills skills) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
value = skills.Values.GetValueOrDefault(skill);
}
});
return value;
}
private static void MutateSkill(School school, string personId, string skill, float value)
{
school.World.Query(in People, (ref PersonIdentity identity, ref PersonSkills skills) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
skills.Values[skill] = value;
}
});
}
private static void MutateNeed(School school, string personId, string need, float value)
{
school.World.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
needs.Values[need] = value;
}
});
}
private static void ClearActivity(School school, string personId)
{
school.World.Query(in People, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
activity = PersonActivity.Idle;
}
});
}
}