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>
212 lines
6.9 KiB
C#
212 lines
6.9 KiB
C#
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;
|
|
}
|
|
}
|