Add recess quarrels, rare yard and gym fights, and apologies.

Bullies remember a victim by a trait field, a defender can join, staff break up a fight with a reprimand, and skip empty time clears leftover clashes so they cannot last forever.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 11:31:06 +03:00
co-authored by Cursor
parent f45347b335
commit 395107c959
20 changed files with 1504 additions and 23 deletions
+504 -2
View File
@@ -21,6 +21,12 @@ internal sealed class ActiveTalkCircle
/// <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>
@@ -32,6 +38,33 @@ internal static class TalkCircleSystem
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)
{
@@ -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<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 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<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);
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<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 (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<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)