using HSchool.Content; using HSchool.People; namespace HSchool.Ai; /// Quarrel, fight and apology math. No world, no clock — inputs to an output. public static class Conflict { public const int OpinionFloor = -100; public static bool RemembersVictim(IReadOnlyList traitIds, DefCatalog catalog) { foreach (var traitId in traitIds) { if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.RemembersVictim) { return true; } } return false; } public static float ConflictChance(IReadOnlyList traitIds, DefCatalog catalog) { var factor = 1f; foreach (var traitId in traitIds) { if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.ConflictChance > 0f) { factor *= trait.ConflictChance; } } return factor; } public static float ApologyChance(IReadOnlyList traitIds, DefCatalog catalog) { var factor = 1f; foreach (var traitId in traitIds) { if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.ApologyChance > 0f) { factor *= trait.ApologyChance; } } return factor; } public static bool WantsQuarrel( int? opinionOfTarget, BehaviorDef? rules, string? rememberedVictimId, string targetId) { if (rememberedVictimId is not null && rememberedVictimId.Equals(targetId, StringComparison.Ordinal)) { return true; } return TalkCircles.IsEnemy(opinionOfTarget, rules); } /// Friends score 0; rivals score the trait-scaled base. That is "чаще", not a coin flip. public static float QuarrelChance( int? opinionOfTarget, IReadOnlyList traits, DefCatalog catalog, BehaviorDef? rules, string? rememberedVictimId, string targetId) { if (!WantsQuarrel(opinionOfTarget, rules, rememberedVictimId, targetId)) { return 0f; } return Math.Clamp((rules?.QuarrelChance ?? 1f) * ConflictChance(traits, catalog), 0f, 1f); } public static bool RollStarts(float chance, int seed) => chance >= 1f || (chance > 0f && TalkCircles.Roll01(seed) < chance); public static bool RollFight(IReadOnlyList traits, DefCatalog catalog, BehaviorDef? rules, int seed) { var chance = Math.Clamp((rules?.FightChance ?? 0.1f) * ConflictChance(traits, catalog), 0f, 0.45f); return chance > 0f && TalkCircles.Roll01(seed) < chance; } public static bool CanFightHere(string? roomDef) => roomDef is not null && (roomDef.Equals("SchoolYard", StringComparison.Ordinal) || roomDef.Equals("GymHall", StringComparison.Ordinal)); public static string FightActionFor(string roomDef) => roomDef.Equals("GymHall", StringComparison.Ordinal) ? TalkActions.FightGym : TalkActions.Fight; public static string QuarrelActionFor(string roomDef) => roomDef.Equals("SchoolYard", StringComparison.Ordinal) ? TalkActions.QuarrelYard : TalkActions.Quarrel; public static bool ShouldDefend(int? opinionOfVictim, BehaviorDef? rules) => opinionOfVictim >= (rules?.DefendOpinionMin ?? 40); public static int QuarrelOpinionDelta(BehaviorDef? rules) => rules?.QuarrelOpinionShift ?? -8; public static int FightOpinionDelta(BehaviorDef? rules) => rules?.FightOpinionShift ?? -16; /// Returns part of , never above . public static int ApologyOpinion(int current, int baseline, int lost, BehaviorDef? rules) { var fraction = Math.Clamp(rules?.ApologyRestoreFraction ?? 0.5f, 0f, 1f); var restored = Math.Max(0, (int)Math.Round(lost * fraction, MidpointRounding.AwayFromZero)); if (restored < 1 && lost > 0) { restored = 1; } return Math.Min(baseline, current + restored); } public static bool HasRival(TalkPlannerContext context) { if (context.SelfId is null) { return false; } foreach (var (personId, _) in context.PersonNodes) { if (personId.Equals(context.SelfId, StringComparison.Ordinal)) { continue; } context.Opinions.TryGetValue(personId, out var opinion); if (WantsQuarrel(opinion, context.Rules, context.BullyVictimId, personId)) { return true; } } return false; } public static float NodeRivalScore(string nodeId, string selfId, TalkPlannerContext context) { var score = 0f; foreach (var (personId, otherNode) in context.PersonNodes) { if (personId.Equals(selfId, StringComparison.Ordinal) || otherNode != nodeId) { continue; } context.Opinions.TryGetValue(personId, out var opinion); if (WantsQuarrel(opinion, context.Rules, context.BullyVictimId, personId)) { score += 5f; } } return score; } public static bool HasHealthNeed(DefCatalog catalog) => catalog.Needs.Values.Any(need => !need.Abstract && need.DefName.Equals("Health", StringComparison.OrdinalIgnoreCase)); /// /// Same victim across days while they stay on the roster and opinion is not at the floor. /// Picks by mixed seed only when memory is empty or stale — never string.GetHashCode. /// public static string? ResolveVictim(Person actor, IReadOnlyList roster, DefCatalog catalog, int seed) { if (!RemembersVictim(actor.Traits, catalog)) { return null; } if (actor.BullyVictimId is { } remembered && roster.Any(person => person.Id.Equals(remembered, StringComparison.Ordinal) && !person.Id.Equals(actor.Id, StringComparison.Ordinal)) && (OpinionStore.Get(actor, remembered) ?? 0) > OpinionFloor) { return remembered; } var candidates = roster .Where(person => person.IsStudent && !person.Id.Equals(actor.Id, StringComparison.Ordinal) && (OpinionStore.Get(actor, person.Id) ?? 0) > OpinionFloor) .OrderBy(person => person.Id, StringComparer.Ordinal) .ToArray(); if (candidates.Length == 0) { actor.BullyVictimId = null; return null; } var index = (seed & int.MaxValue) % candidates.Length; var pick = candidates[index].Id; actor.BullyVictimId = pick; return pick; } }