378 lines
11 KiB
C#
378 lines
11 KiB
C#
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);
|
|
}
|