Short offense list for the pupil card and save, capped by BehaviorDef — not a discipline score.
1247 lines
42 KiB
C#
1247 lines
42 KiB
C#
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>toId</c>. Used to cap apologies.</summary>
|
||
public Dictionary<string, int> OpinionBaseline { get; init; } = new(StringComparer.Ordinal);
|
||
}
|
||
|
||
/// <summary>Forms 2–4 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;
|
||
}
|
||
});
|
||
}
|
||
}
|