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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user