using HSchool.Content; using HSchool.People; namespace HSchool.Ai; /// Inputs the planner needs for friend pull and lunch avoidance. Built in simulation. public readonly record struct TalkPlannerContext( string? SelfId, IReadOnlyDictionary Opinions, IReadOnlyDictionary PersonNodes, string? ClassId, bool HasPhone, bool FriendPullActive, BehaviorDef? Rules); /// /// Pure talk-circle rules: who joins, which topic, opinion and skill math. No world access. /// public static class TalkCircles { public static bool BlocksOnLesson(string actionId) => TalkActions.IsTalk(actionId); public static int MaxSize(string actionId, BehaviorDef? rules, IReadOnlyList 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 items) => items.Any(item => item.Def.Equals("Phone", StringComparison.Ordinal) && item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)); public static float Initiative(IReadOnlyList 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 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; } /// Score a room node for friend pull on break or lunch. 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; } /// True when this lunch node should be avoided because an enemy sits there. 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; } /// Best shared language skill id among participants, or null. public static string? SharedLanguage( DefCatalog catalog, IReadOnlyList 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); /// Invite priority: friends, classmates, others. Enemies never invited. public static IReadOnlyList RankInvitees( Person initiator, IReadOnlyList 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); }