Merge branch 'phase/44-quarrel-fight'
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # docs/phases/README.md # src/HSchool.Content/PeopleDefs.cs # src/HSchool.People/Roster.cs # src/HSchool.People/Seed.cs
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Ai;
|
||||
|
||||
/// <summary>Quarrel, fight and apology math. No world, no clock — inputs to an output.</summary>
|
||||
public static class Conflict
|
||||
{
|
||||
public const int OpinionFloor = -100;
|
||||
|
||||
public static bool RemembersVictim(IReadOnlyList<string> 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<string> 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<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Friends score 0; rivals score the trait-scaled base. That is "чаще", not a coin flip.</summary>
|
||||
public static float QuarrelChance(
|
||||
int? opinionOfTarget,
|
||||
IReadOnlyList<string> 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<string> 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;
|
||||
|
||||
/// <summary>Returns part of <paramref name="lost"/>, never above <paramref name="baseline"/>.</summary>
|
||||
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));
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>string.GetHashCode</c>.
|
||||
/// </summary>
|
||||
public static string? ResolveVictim(Person actor, IReadOnlyList<Person> 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;
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
{
|
||||
|
||||
@@ -11,7 +11,8 @@ public readonly record struct TalkPlannerContext(
|
||||
string? ClassId,
|
||||
bool HasPhone,
|
||||
bool FriendPullActive,
|
||||
BehaviorDef? Rules);
|
||||
BehaviorDef? Rules,
|
||||
string? BullyVictimId = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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<string> 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -227,6 +227,20 @@ public sealed class TraitDef : Def
|
||||
/// higher (jealous). Ignored without affinity rules.
|
||||
/// </summary>
|
||||
public int AffinityBreakOffset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies quarrel and fight chance. Hot-tempered and bully above 1. Missing is 1.
|
||||
/// </summary>
|
||||
public float ConflictChance { get; init; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// This person keeps one victim id until they leave or opinion hits the floor.
|
||||
/// A field, not a <c>defName == "Bully"</c> branch.
|
||||
/// </summary>
|
||||
public bool RemembersVictim { get; init; }
|
||||
|
||||
/// <summary>Multiplies the chance to start an apology. Bully below 1. Missing is 1.</summary>
|
||||
public float ApologyChance { get; init; } = 1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -528,6 +542,24 @@ public sealed class BehaviorDef : Def
|
||||
/// <summary>Per-decision chance a pupil in class tries to start a whisper, before initiative.</summary>
|
||||
public float WhisperStartChance { get; init; } = 0.12f;
|
||||
|
||||
/// <summary>Rivals always roll this before traits. 1 means every enemy pair can start a quarrel.</summary>
|
||||
public float QuarrelChance { get; init; } = 1f;
|
||||
|
||||
/// <summary>Base chance a yard or gym clash becomes a fight. Traits scale it; kept rare.</summary>
|
||||
public float FightChance { get; init; } = 0.1f;
|
||||
|
||||
/// <summary>Opinion shift per other participant when a quarrel ends. Stronger than rude talk (−3).</summary>
|
||||
public int QuarrelOpinionShift { get; init; } = -8;
|
||||
|
||||
/// <summary>Opinion shift when a fight ends. No health need is touched.</summary>
|
||||
public int FightOpinionShift { get; init; } = -16;
|
||||
|
||||
/// <summary>Fraction of lost opinion an apology returns. Never climbs past the pre-quarrel value.</summary>
|
||||
public float ApologyRestoreFraction { get; init; } = 0.5f;
|
||||
|
||||
/// <summary>Opinion of the victim at or above this — a third person may join the quarrel.</summary>
|
||||
public int DefendOpinionMin { get; init; } = 40;
|
||||
|
||||
public static IReadOnlyList<OpinionBand> DefaultOpinionBands { get; } =
|
||||
[
|
||||
new() { Min = 70, Id = "OpinionCloseFriend" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -63,6 +63,12 @@ public sealed record Person
|
||||
/// <summary>Crushes and the one pair. Null without an orientation pack. Mutable overlay.</summary>
|
||||
public PersonBonds? Bonds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? BullyVictimId { get; set; }
|
||||
|
||||
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "еде",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> SnapshotPersonNodes(School school)
|
||||
|
||||
@@ -136,6 +136,8 @@ public sealed class School : IDisposable
|
||||
|
||||
internal Dictionary<string, string> TalkCircleByPerson { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
internal Dictionary<string, ApologyDebt> 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);
|
||||
|
||||
@@ -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>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>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user