Add talk circles with TopicDef, staff/phone chat, and opinion outcomes.

This commit is contained in:
Leonid Pershin
2026-08-20 08:55:55 +03:00
parent 9d96108516
commit 47fc10da02
26 changed files with 1948 additions and 175 deletions
+10
View File
@@ -21,6 +21,11 @@ internal static class ActivitySystem
return false;
}
if (TalkActions.IsTalk(actionId))
{
return TalkCircleSystem.TryStart(school, personId, actionId);
}
if (!school.Catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract)
{
return false;
@@ -114,6 +119,11 @@ internal static class ActivitySystem
return;
}
if (TalkActions.IsTalk(activity.ActionId))
{
return;
}
var location = school.Map?.NodeDef(presence.NodeId ?? "");
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
{
+9
View File
@@ -12,6 +12,7 @@ public static class PersonLogTypes
{
public const string ActionStarted = "action-started";
public const string ActionEnded = "action-ended";
public const string TalkEnded = "talk-ended";
public const string ApparelReplaced = "apparel-replaced";
public const string ApparelChanged = "apparel-changed";
}
@@ -58,6 +59,14 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name);
}
if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal))
{
var topic = catalog.Topics.TryGetValue(ThingDef ?? "", out var def)
? catalog.Label(locale, def)
: ThingDef ?? Type;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic);
}
return Type;
}
}
+58 -9
View File
@@ -324,6 +324,23 @@ internal static class PresenceSystem
{
var now = school.Clock.Time;
var walks = school.Walks!;
var slot = SchoolDay.At(school.Catalog!, now, school.SchoolWeekDays);
var weekday = SchoolDay.WeekdayIndex(now);
var lessons = Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday);
var bound = Duty.IsOtherStaff(person)
? slot.Kind != DaySlotKind.Outside
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
if (activity.IsActive && TalkActions.IsTalk(activity.ActionId))
{
if (!bound)
{
return null;
}
TalkCircleSystem.Interrupt(school, person.Id);
activity = PersonActivity.Idle;
}
if (!presence.IsOnCampus)
{
intent = Intent.None;
@@ -369,16 +386,11 @@ internal static class PresenceSystem
return null;
}
var slot = SchoolDay.At(school.Catalog!, now, school.SchoolWeekDays);
var weekday = SchoolDay.WeekdayIndex(now);
var lessons = Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday);
var bound = Duty.IsOtherStaff(person)
? slot.Kind != DaySlotKind.Outside
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
var frame = school.Catalog!.DayFrame;
var lunchOpen = frame is not null
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
var apparel = ApparelPresence.Build(school, person, ClassOf(school, person));
var talk = BuildTalkContext(school, person, bound, lunchOpen, slot);
var state = new ActorState(
presence.NodeId,
presence.DestinationId,
@@ -392,7 +404,8 @@ internal static class PresenceSystem
needs.Values,
intent,
lunchOpen,
apparel);
apparel,
talk);
var decision = DecisionPlanner.Decide(
school.Catalog!,
school.Map!,
@@ -401,17 +414,19 @@ internal static class PresenceSystem
(node, thing) => occupied.GetValueOrDefault((node, thing)));
var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId);
var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId);
if (decision.WalkTo is not null
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
&& activity.IsActive
&& !changing)
&& !changing
&& !inTalk)
{
activity = PersonActivity.Idle;
}
intent = decision.Intent;
if (decision.WalkTo is not null && !changing)
if (decision.WalkTo is not null && !changing && !inTalk)
{
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
}
@@ -523,6 +538,40 @@ internal static class PresenceSystem
return new Intent(kind, row.GoalId, row.GoalWeight, row.GoalAction);
}
private static TalkPlannerContext BuildTalkContext(
School school,
Person person,
bool boundToLesson,
bool lunchOpen,
DaySlot slot)
{
var opinions = person.Opinions as IReadOnlyDictionary<string, int> ?? new Dictionary<string, int>(StringComparer.Ordinal);
var nodes = SnapshotPersonNodes(school);
var friendPull = !boundToLesson && (slot.Kind == DaySlotKind.Break || lunchOpen);
return new TalkPlannerContext(
person.Id,
opinions,
nodes,
person.ClassId,
TalkCircles.HasPhone(person.Items),
friendPull,
school.Catalog?.BehaviorRules);
}
private static Dictionary<string, string> SnapshotPersonNodes(School school)
{
var nodes = new Dictionary<string, string>(StringComparer.Ordinal);
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
{
if (presence.IsOnCampus && presence.NodeId is not null)
{
nodes[identity.Id] = presence.NodeId;
}
});
return nodes;
}
private static SchoolClass? ClassOf(School school, Person person)
{
if (person.ClassId is null)
+9
View File
@@ -131,6 +131,10 @@ public sealed class School : IDisposable
internal Dictionary<string, string?> LoggedActivity { get; } = new(StringComparer.Ordinal);
internal Dictionary<string, ActiveTalkCircle> TalkCirclesById { get; } = new(StringComparer.Ordinal);
internal Dictionary<string, string> TalkCircleByPerson { get; } = new(StringComparer.Ordinal);
internal void ResetDayLog()
{
_dayLog.Clear();
@@ -339,6 +343,11 @@ public sealed class School : IDisposable
PresenceSystem.Enqueue(this, id);
}
foreach (var id in TalkCircleSystem.Apply(this, gameMinutes))
{
PresenceSystem.Enqueue(this, id);
}
PersonDayLog.Sync(this);
if (Catalog is not null)
+487
View File
@@ -0,0 +1,487 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
internal sealed class ActiveTalkCircle
{
public required string Id { get; init; }
public required string ActionId { get; init; }
public required string TopicId { get; init; }
public required string NodeId { get; init; }
public required List<string> Members { get; init; }
public float RemainingMinutes { get; set; }
}
/// <summary>Forms 24 person talk circles, applies outcomes, writes topic log lines.</summary>
internal static class TalkCircleSystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, PersonSkills, PersonNeeds, Presence, PersonActivity>();
public static bool IsInCircle(School school, string personId) =>
school.TalkCircleByPerson.ContainsKey(personId);
/// <summary>Ends an active circle when duty overrides break talk (bell rang).</summary>
public static void Interrupt(School school, string personId)
{
if (!school.TalkCircleByPerson.TryGetValue(personId, out var circleId)
|| !school.TalkCirclesById.TryGetValue(circleId, out var circle))
{
return;
}
Finish(school, circle);
}
public static bool TryStart(School school, string personId, string actionId)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return false;
}
if (!TalkActions.IsTalk(actionId) || !school.Catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract)
{
return false;
}
if (school.TalkCircleByPerson.ContainsKey(personId))
{
return true;
}
var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
if (person is null)
{
return false;
}
string? nodeId = null;
var busy = false;
school.World.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
{
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
nodeId = presence.NodeId;
busy = activity.IsActive;
});
if (nodeId is null || busy)
{
return false;
}
var location = school.Map.NodeDef(nodeId);
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
{
return false;
}
if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal))
{
return TryStartPhone(school, person, nodeId, action);
}
var existing = FindOpenCircle(school, nodeId, actionId);
if (existing is not null)
{
return JoinCircle(school, existing, personId, action);
}
return TryStartGroup(school, person, nodeId, action);
}
public static IReadOnlyList<string> Apply(School school, double gameMinutes)
{
if (gameMinutes <= 0 || school.Catalog is null || school.Roster is null)
{
return [];
}
var minutes = (float)gameMinutes;
var completed = new List<string>();
foreach (var circle in school.TalkCirclesById.Values.ToArray())
{
circle.RemainingMinutes -= minutes;
SyncActivity(school, circle);
if (circle.RemainingMinutes > 0)
{
continue;
}
Finish(school, circle);
completed.AddRange(circle.Members);
}
completed.Sort(StringComparer.Ordinal);
return completed;
}
private static bool TryStartPhone(School school, Person initiator, string nodeId, ActionDef action)
{
if (!TalkCircles.HasPhone(initiator.Items))
{
return false;
}
var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: true);
var ranked = TalkCircles.RankInvitees(initiator, candidates, school.Catalog!.BehaviorRules);
if (ranked.Count == 0)
{
return false;
}
var partnerId = ranked[0];
var partner = school.Roster!.People.First(row => row.Id.Equals(partnerId, StringComparison.Ordinal));
if (!TalkCircles.HasPhone(partner.Items))
{
return false;
}
var topicId = TalkCircles.PickTopic(
school.Catalog!,
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20))
?? school.Catalog!.Topics.Values.First(topic => !topic.Abstract).DefName;
var circle = new ActiveTalkCircle
{
Id = $"phone-{initiator.Id}-{partnerId}",
ActionId = action.DefName,
TopicId = topicId,
NodeId = nodeId,
Members = [initiator.Id, partnerId],
RemainingMinutes = action.Minutes,
};
Register(school, circle, action);
return true;
}
private static bool TryStartGroup(School school, Person initiator, string nodeId, ActionDef action)
{
var rules = school.Catalog!.BehaviorRules;
var max = TalkCircles.MaxSize(action.DefName, rules, initiator.Traits, school.Catalog);
var candidates = GatherCandidates(school, initiator.Id, nodeId, requirePhone: false);
var ranked = TalkCircles.RankInvitees(initiator, candidates, rules);
var members = new List<string> { initiator.Id };
foreach (var id in ranked)
{
if (members.Count >= max)
{
break;
}
members.Add(id);
}
var min = rules?.TalkCircleMin ?? 2;
if (members.Count < min)
{
return false;
}
var topicId = TalkCircles.PickTopic(
school.Catalog,
initiator,
initiator.AgeOn(school.Clock.Time),
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21))
?? school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName;
var circle = new ActiveTalkCircle
{
Id = $"talk-{initiator.Id}-{nodeId}-{school.Clock.Time.Ticks}",
ActionId = action.DefName,
TopicId = topicId,
NodeId = nodeId,
Members = members,
RemainingMinutes = action.Minutes,
};
Register(school, circle, action);
return true;
}
private static bool JoinCircle(School school, ActiveTalkCircle circle, string personId, ActionDef action)
{
var rules = school.Catalog!.BehaviorRules;
var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal));
var max = TalkCircles.MaxSize(action.DefName, rules, person.Traits, school.Catalog);
if (circle.Members.Count >= max || circle.Members.Contains(personId))
{
return false;
}
circle.Members.Add(personId);
circle.Members.Sort(StringComparer.Ordinal);
school.TalkCircleByPerson[personId] = circle.Id;
SetActivity(school, personId, action.DefName, circle.TopicId, circle.RemainingMinutes);
return true;
}
private static ActiveTalkCircle? FindOpenCircle(School school, string nodeId, string actionId)
{
foreach (var circle in school.TalkCirclesById.Values)
{
if (circle.NodeId.Equals(nodeId, StringComparison.Ordinal)
&& circle.ActionId.Equals(actionId, StringComparison.Ordinal)
&& circle.RemainingMinutes > 0)
{
var rules = school.Catalog!.BehaviorRules;
var max = rules?.TalkCircleMax ?? 4;
if (circle.Members.Count < max)
{
return circle;
}
}
}
return null;
}
private static IReadOnlyList<TalkCircles.Candidate> GatherCandidates(
School school,
string exceptId,
string nodeId,
bool requirePhone)
{
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var list = new List<TalkCircles.Candidate>();
school.World.Query(
in People,
(ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) =>
{
if (identity.Id.Equals(exceptId, StringComparison.Ordinal)
|| presence.NodeId is null
|| !presence.NodeId.Equals(nodeId, StringComparison.Ordinal))
{
return;
}
if (!roster.TryGetValue(identity.Id, out var person))
{
return;
}
if (requirePhone && !TalkCircles.HasPhone(person.Items))
{
return;
}
list.Add(new TalkCircles.Candidate(
identity.Id,
roles.ClassId,
!activity.IsActive && !school.TalkCircleByPerson.ContainsKey(identity.Id),
school.TalkCircleByPerson.ContainsKey(identity.Id),
TalkCircles.HasPhone(person.Items)));
});
return list.Where(candidate => candidate.IsIdle).ToArray();
}
private static void Register(School school, ActiveTalkCircle circle, ActionDef action)
{
school.TalkCirclesById[circle.Id] = circle;
foreach (var member in circle.Members)
{
school.TalkCircleByPerson[member] = circle.Id;
SetActivity(school, member, action.DefName, circle.TopicId, circle.RemainingMinutes);
}
}
private static void SetActivity(School school, string personId, string actionId, string topicId, float remaining)
{
school.World.Query(
in People,
(ref PersonIdentity identity, ref PersonActivity activity) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
activity = new PersonActivity(actionId, topicId, remaining);
}
});
}
private static void SyncActivity(School school, ActiveTalkCircle circle)
{
foreach (var member in circle.Members)
{
SetActivity(school, member, circle.ActionId, circle.TopicId, Math.Max(0f, circle.RemainingMinutes));
}
}
private static void Finish(School school, ActiveTalkCircle circle)
{
var catalog = school.Catalog!;
var rules = catalog.BehaviorRules;
var roster = school.Roster!;
var people = circle.Members
.Select(id => roster.People.First(person => person.Id.Equals(id, StringComparison.Ordinal)))
.ToArray();
if (!catalog.Topics.TryGetValue(circle.TopicId, out var topic))
{
topic = catalog.Topics.Values.First(def => !def.Abstract);
}
if (!catalog.Actions.TryGetValue(circle.ActionId, out var action))
{
action = catalog.Actions[TalkActions.Chat];
}
var hours = action.Minutes / 60f;
var sharedLanguage = TalkCircles.SharedLanguage(catalog, people, rules) is not null;
var language = TalkCircles.SharedLanguage(catalog, people, rules);
foreach (var person in people)
{
if (!string.IsNullOrWhiteSpace(action.Need)
&& catalog.Needs.TryGetValue(action.Need, out var need))
{
var current = NeedOf(school, person.Id, action.Need);
if (float.IsNaN(current))
{
current = need.Min;
}
var next = ActionStepper.ApplyNeedGain(current, action, need);
MutateNeed(school, person.Id, action.Need, next);
}
if (catalog.Skills.TryGetValue("Communication", out var communicationSkill))
{
var level = SkillOf(school, person.Id, "Communication");
var next = TalkCircles.CommunicationGain(level, communicationSkill, hours, rules);
MutateSkill(school, person.Id, "Communication", next);
}
if (language is not null
&& catalog.Skills.TryGetValue(language, out var languageSkill)
&& SkillOf(school, person.Id, language) < languageSkill.Range.Max)
{
var level = SkillOf(school, person.Id, language);
var next = TalkCircles.LanguageGain(level, languageSkill, hours, rules);
MutateSkill(school, person.Id, language, next);
}
foreach (var other in people)
{
if (other.Id.Equals(person.Id, StringComparison.Ordinal))
{
continue;
}
var communication = SkillOf(school, person.Id, "Communication");
var appearance = AppearanceOf(school, other);
var delta = TalkCircles.OpinionDelta(
person,
other,
topic,
communication,
sharedLanguage,
circle.ActionId,
appearance,
rules,
catalog);
if (delta == 0)
{
continue;
}
var current = OpinionStore.Get(person, other.Id) ?? 0;
OpinionStore.Set(person, other.Id, current + delta);
}
school.AppendDayLog(new PersonLogEvent(
person.Id,
school.Clock.Time,
PersonLogTypes.TalkEnded,
circle.TopicId));
}
foreach (var member in circle.Members)
{
school.TalkCircleByPerson.Remove(member);
ClearActivity(school, member);
}
school.TalkCirclesById.Remove(circle.Id);
}
private static ApparelIssue AppearanceOf(School school, Person person)
{
var schoolClass = person.ClassId is { } classId
? school.Roster!.Classes.FirstOrDefault(row => row.Id.Equals(classId, StringComparison.Ordinal))
: null;
var mode = ApparelPresence.ModeAt(school, person, schoolClass);
return ApparelDresser.CurrentIssues(school, person, mode);
}
private static float NeedOf(School school, string personId, string need)
{
var value = float.NaN;
school.World.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
value = needs.Values.GetValueOrDefault(need, float.NaN);
}
});
return value;
}
private static float SkillOf(School school, string personId, string skill)
{
var value = 0f;
school.World.Query(in People, (ref PersonIdentity identity, ref PersonSkills skills) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
value = skills.Values.GetValueOrDefault(skill);
}
});
return value;
}
private static void MutateSkill(School school, string personId, string skill, float value)
{
school.World.Query(in People, (ref PersonIdentity identity, ref PersonSkills skills) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
skills.Values[skill] = value;
}
});
}
private static void MutateNeed(School school, string personId, string need, float value)
{
school.World.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
needs.Values[need] = value;
}
});
}
private static void ClearActivity(School school, string personId)
{
school.World.Query(in People, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal))
{
activity = PersonActivity.Idle;
}
});
}
}