Add talk circles with TopicDef, staff/phone chat, and opinion outcomes.
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Ai;
|
||||
|
||||
/// <summary>Inputs the planner needs for friend pull and lunch avoidance. Built in simulation.</summary>
|
||||
public readonly record struct TalkPlannerContext(
|
||||
string? SelfId,
|
||||
IReadOnlyDictionary<string, int> Opinions,
|
||||
IReadOnlyDictionary<string, string> PersonNodes,
|
||||
string? ClassId,
|
||||
bool HasPhone,
|
||||
bool FriendPullActive,
|
||||
BehaviorDef? Rules);
|
||||
|
||||
/// <summary>
|
||||
/// Pure talk-circle rules: who joins, which topic, opinion and skill math. No world access.
|
||||
/// </summary>
|
||||
public static class TalkCircles
|
||||
{
|
||||
public static bool BlocksOnLesson(string actionId) => TalkActions.IsTalk(actionId);
|
||||
|
||||
public static int MaxSize(string actionId, BehaviorDef? rules, IReadOnlyList<string> traitIds, DefCatalog catalog)
|
||||
{
|
||||
if (actionId.Equals(TalkActions.PhoneChat, StringComparison.Ordinal))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
var max = rules?.TalkCircleMax ?? 4;
|
||||
foreach (var traitId in traitIds)
|
||||
{
|
||||
if (catalog.Traits.TryGetValue(traitId, out var trait))
|
||||
{
|
||||
max += trait.TalkCircleBonus;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.Clamp(max, rules?.TalkCircleMin ?? 2, 4);
|
||||
}
|
||||
|
||||
public static bool IsFriend(int? opinion, BehaviorDef? rules) =>
|
||||
opinion >= (rules?.TalkFriendThreshold ?? 40);
|
||||
|
||||
public static bool IsEnemy(int? opinion, BehaviorDef? rules) =>
|
||||
opinion <= (rules?.TalkEnemyThreshold ?? -40);
|
||||
|
||||
public static bool HasPhone(IReadOnlyList<InventoryItem> items) =>
|
||||
items.Any(item =>
|
||||
item.Def.Equals("Phone", StringComparison.Ordinal)
|
||||
&& item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal));
|
||||
|
||||
public static float Initiative(IReadOnlyList<string> traitIds, DefCatalog catalog)
|
||||
{
|
||||
var weight = 1f;
|
||||
foreach (var traitId in traitIds)
|
||||
{
|
||||
if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.TalkInitiative > 0f)
|
||||
{
|
||||
weight *= trait.TalkInitiative;
|
||||
}
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
public static float OpinionMultiplier(IReadOnlyList<string> traitIds, DefCatalog catalog)
|
||||
{
|
||||
var mult = 1f;
|
||||
foreach (var traitId in traitIds)
|
||||
{
|
||||
if (catalog.Traits.TryGetValue(traitId, out var trait) && trait.TalkOpinionMultiplier > 0f)
|
||||
{
|
||||
mult *= trait.TalkOpinionMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
return mult;
|
||||
}
|
||||
|
||||
/// <summary>Score a room node for friend pull on break or lunch.</summary>
|
||||
public static float NodeFriendScore(
|
||||
string nodeId,
|
||||
string selfId,
|
||||
TalkPlannerContext context)
|
||||
{
|
||||
if (!context.FriendPullActive)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
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 (IsFriend(opinion, context.Rules))
|
||||
{
|
||||
score += 2f;
|
||||
}
|
||||
else if (IsEnemy(opinion, context.Rules))
|
||||
{
|
||||
score -= 3f;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// <summary>True when this lunch node should be avoided because an enemy sits there.</summary>
|
||||
public static bool LunchNodeBlockedByEnemy(
|
||||
string nodeId,
|
||||
string selfId,
|
||||
TalkPlannerContext context,
|
||||
bool hasAlternative)
|
||||
{
|
||||
if (!hasAlternative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (personId, otherNode) in context.PersonNodes)
|
||||
{
|
||||
if (personId.Equals(selfId, StringComparison.Ordinal) || otherNode != nodeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
context.Opinions.TryGetValue(personId, out var opinion);
|
||||
if (IsEnemy(opinion, context.Rules))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool RoleFitsTopic(TopicDef topic, bool isStudent, bool isStaff, bool isParent)
|
||||
{
|
||||
if (topic.Roles.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var role in topic.Roles)
|
||||
{
|
||||
if (role.Equals(PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && isStudent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (role.Equals(PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && isStaff)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (role.Equals(PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && isParent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool AgeFits(TopicDef topic, int age)
|
||||
{
|
||||
if (topic.Age is not { } range)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return age >= range.Min && age <= range.Max;
|
||||
}
|
||||
|
||||
public static string? PickTopic(
|
||||
DefCatalog catalog,
|
||||
Person picker,
|
||||
int age,
|
||||
int seed)
|
||||
{
|
||||
var candidates = new List<(TopicDef Topic, float Weight)>();
|
||||
foreach (var topic in catalog.Topics.Values)
|
||||
{
|
||||
if (topic.Abstract
|
||||
|| !RoleFitsTopic(topic, picker.IsStudent, picker.IsStaff, picker.IsParent)
|
||||
|| !AgeFits(topic, age))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var weight = 1f;
|
||||
foreach (var traitId in picker.Traits)
|
||||
{
|
||||
if (!catalog.Traits.TryGetValue(traitId, out var trait))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var bias in trait.TalkTagWeights)
|
||||
{
|
||||
if (topic.Tags.Contains(bias.Tag, StringComparer.Ordinal))
|
||||
{
|
||||
weight *= bias.Weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.Add((topic, weight));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var total = candidates.Sum(pair => pair.Weight);
|
||||
var roll = (seed & int.MaxValue) % Math.Max(1, (int)(total * 1000)) / 1000f;
|
||||
var cursor = 0f;
|
||||
foreach (var (topic, weight) in candidates.OrderBy(pair => pair.Topic.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
cursor += weight / total;
|
||||
if (roll <= cursor)
|
||||
{
|
||||
return topic.DefName;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[^1].Topic.DefName;
|
||||
}
|
||||
|
||||
/// <summary>Best shared language skill id among participants, or null.</summary>
|
||||
public static string? SharedLanguage(
|
||||
DefCatalog catalog,
|
||||
IReadOnlyList<Person> participants,
|
||||
BehaviorDef? rules)
|
||||
{
|
||||
var threshold = rules?.TalkLanguageThreshold ?? 25;
|
||||
string? best = null;
|
||||
var bestLevel = 0f;
|
||||
foreach (var skill in catalog.Skills.Values.Where(def =>
|
||||
!def.Abstract
|
||||
&& !def.DefName.Equals("Communication", StringComparison.Ordinal)
|
||||
&& (def.AdultChance > 0f || def.DefName.EndsWith("Language", StringComparison.Ordinal))))
|
||||
{
|
||||
var min = float.MaxValue;
|
||||
foreach (var person in participants)
|
||||
{
|
||||
if (!person.Skills.TryGetValue(skill.DefName, out var level) || level < threshold)
|
||||
{
|
||||
min = 0f;
|
||||
break;
|
||||
}
|
||||
|
||||
min = Math.Min(min, level);
|
||||
}
|
||||
|
||||
if (min >= threshold && min > bestLevel)
|
||||
{
|
||||
bestLevel = min;
|
||||
best = skill.DefName;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static int AppearanceModifier(ApparelIssue issues) =>
|
||||
issues == ApparelIssue.None ? 2 : issues.HasFlag(ApparelIssue.Formality) ? -2 : 0;
|
||||
|
||||
public static int OpinionDelta(
|
||||
Person from,
|
||||
Person to,
|
||||
TopicDef topic,
|
||||
float communication,
|
||||
bool sharedLanguage,
|
||||
string actionId,
|
||||
ApparelIssue targetAppearance,
|
||||
BehaviorDef? rules,
|
||||
DefCatalog catalog)
|
||||
{
|
||||
var baseShift = topic.OpinionShift;
|
||||
if (topic.Tags.Contains(TopicTags.Appearance, StringComparer.Ordinal))
|
||||
{
|
||||
baseShift += AppearanceModifier(targetAppearance);
|
||||
}
|
||||
|
||||
var commScale = 1f + (communication / 100f) * (rules?.TalkCommunicationOpinionScale ?? 0.5f);
|
||||
var mult = OpinionMultiplier(from.Traits.ToList(), catalog) * commScale;
|
||||
if (!sharedLanguage)
|
||||
{
|
||||
mult *= rules?.TalkNoLanguageOpinionMultiplier ?? 0.15f;
|
||||
if (baseShift > 0)
|
||||
{
|
||||
baseShift = Math.Max(1, baseShift / 3);
|
||||
}
|
||||
}
|
||||
|
||||
if (actionId.Equals(TalkActions.PhoneChat, StringComparison.Ordinal))
|
||||
{
|
||||
mult *= rules?.TalkPhoneOpinionMultiplier ?? 0.5f;
|
||||
}
|
||||
|
||||
var delta = (int)Math.Round(baseShift * mult, MidpointRounding.AwayFromZero);
|
||||
if (!sharedLanguage && delta > 0)
|
||||
{
|
||||
delta = Math.Min(delta, 1);
|
||||
}
|
||||
else if (!sharedLanguage && delta == 0 && baseShift <= 0)
|
||||
{
|
||||
delta = -1;
|
||||
}
|
||||
|
||||
return Math.Clamp(delta, -20, 20);
|
||||
}
|
||||
|
||||
public static float CommunicationGain(
|
||||
float current,
|
||||
SkillDef skill,
|
||||
float hours,
|
||||
BehaviorDef? rules) =>
|
||||
Math.Clamp(current + (rules?.TalkSkillPerHour ?? 0.02f) * hours, skill.Range.Min, skill.Range.Max);
|
||||
|
||||
public static float LanguageGain(
|
||||
float current,
|
||||
SkillDef skill,
|
||||
float hours,
|
||||
BehaviorDef? rules) =>
|
||||
Math.Clamp(current + (rules?.TalkLanguageSkillPerHour ?? 0.005f) * hours, skill.Range.Min, skill.Range.Max);
|
||||
|
||||
/// <summary>Invite priority: friends, classmates, others. Enemies never invited.</summary>
|
||||
public static IReadOnlyList<string> RankInvitees(
|
||||
Person initiator,
|
||||
IReadOnlyList<Candidate> candidates,
|
||||
BehaviorDef? rules)
|
||||
{
|
||||
return candidates
|
||||
.Where(candidate => !candidate.Id.Equals(initiator.Id, StringComparison.Ordinal))
|
||||
.Where(candidate =>
|
||||
{
|
||||
initiator.Opinions.TryGetValue(candidate.Id, out var opinion);
|
||||
return !IsEnemy(opinion, rules);
|
||||
})
|
||||
.OrderByDescending(candidate =>
|
||||
{
|
||||
initiator.Opinions.TryGetValue(candidate.Id, out var opinion);
|
||||
if (IsFriend(opinion, rules))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (initiator.ClassId is not null
|
||||
&& candidate.ClassId is not null
|
||||
&& initiator.ClassId.Equals(candidate.ClassId, StringComparison.Ordinal))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 1;
|
||||
})
|
||||
.ThenBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.Select(candidate => candidate.Id)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public readonly record struct Candidate(
|
||||
string Id,
|
||||
string? ClassId,
|
||||
bool IsIdle,
|
||||
bool InCircle,
|
||||
bool HasPhone);
|
||||
}
|
||||
Reference in New Issue
Block a user