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;
}
}