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:
+38
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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