Merge branch 'phase/42-talk-circles'

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/phases/README.md
#	src/HSchool.Simulation/PersonLog.cs
This commit is contained in:
Leonid Pershin
2026-08-20 09:03:38 +03:00
27 changed files with 1949 additions and 176 deletions
+38 -16
View File
@@ -41,7 +41,8 @@ public readonly record struct ActorState(
IReadOnlyDictionary<string, float> Needs,
Intent Intent,
bool LunchWindowOpen = false,
ApparelActor Apparel = default);
ApparelActor Apparel = default,
TalkPlannerContext Talk = default);
/// <summary>
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
@@ -254,6 +255,16 @@ public static class DecisionPlanner
return Intent.None;
}
if (state.BoundToLesson && TalkCircles.BlocksOnLesson(action.DefName))
{
return Intent.None;
}
if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal) && !state.Talk.HasPhone)
{
return Intent.None;
}
if (RoomFor(catalog, map, walks, state, occupied, action) is null)
{
return Intent.None;
@@ -396,8 +407,9 @@ public static class DecisionPlanner
}
string? best = null;
var bestCost = float.PositiveInfinity;
var bestScore = float.NegativeInfinity;
var from = state.NodeId ?? walks.TerritoryId;
var candidates = new List<string>();
foreach (var room in map.Rooms)
{
if (!action.Room.Equals(room.Def, StringComparison.Ordinal))
@@ -410,27 +422,37 @@ public static class DecisionPlanner
continue;
}
var cost = walks.Minutes(from, room.Id);
if (float.IsInfinity(cost) || cost > bestCost)
{
continue;
}
if (cost < bestCost || best is null || string.CompareOrdinal(room.Id, best) < 0)
{
best = room.Id;
bestCost = cost;
}
candidates.Add(room.Id);
}
if (map.Territory is { } territory
&& action.Room.Equals(territory.Def, StringComparison.Ordinal)
&& HasSlot(catalog, map, occupied, territory.Id, action.Thing))
{
var cost = walks.Minutes(from, territory.Id);
if (!float.IsInfinity(cost) && (best is null || cost < bestCost || (cost == bestCost && string.CompareOrdinal(territory.Id, best) < 0)))
candidates.Add(territory.Id);
}
var hasAlternative = candidates.Count > 1;
foreach (var nodeId in candidates)
{
if (action.Lunch
&& TalkCircles.LunchNodeBlockedByEnemy(nodeId, state.Talk.SelfId ?? "", state.Talk, hasAlternative))
{
best = territory.Id;
continue;
}
var cost = walks.Minutes(from, nodeId);
if (float.IsInfinity(cost))
{
continue;
}
var score = -cost + TalkCircles.NodeFriendScore(nodeId, state.Talk.SelfId ?? "", state.Talk);
if (score > bestScore
|| (score == bestScore && (best is null || string.CompareOrdinal(nodeId, best) < 0)))
{
best = nodeId;
bestScore = score;
}
}
+377
View File
@@ -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);
}