Update decision-making phase and enhance localization for presence tracking
- Marked tasks as complete in the decision-making phase documentation, indicating readiness for implementation. - Updated the README to reflect the completion status of the decision-making phase. - Enhanced localization strings to include new presence tracking features, improving user experience. - Revised the game screen logic to display real-time presence status, including walking states for individuals. - Added tests to validate the new localization strings and presence functionalities, ensuring robust performance.
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Ai;
|
||||
|
||||
public enum GoalKind
|
||||
{
|
||||
None,
|
||||
Duty,
|
||||
Need,
|
||||
Leisure,
|
||||
}
|
||||
|
||||
/// <summary>What this person is currently pursuing. Compared against a new winner using switchMargin.</summary>
|
||||
public readonly record struct Intent(GoalKind Kind, string? Id, float Weight, string? ActionId)
|
||||
{
|
||||
public static Intent None { get; } = new(GoalKind.None, null, 0f, null);
|
||||
|
||||
public bool IsSet => Kind != GoalKind.None;
|
||||
}
|
||||
|
||||
/// <summary>Walk, start an action, or stay. Heading home is not a goal — the day plan handles it.</summary>
|
||||
public readonly record struct Decision(
|
||||
string? WalkTo,
|
||||
string? StartAction,
|
||||
Intent Intent)
|
||||
{
|
||||
public static Decision Stay(Intent intent) => new(null, null, intent);
|
||||
}
|
||||
|
||||
public readonly record struct ActorState(
|
||||
string? NodeId,
|
||||
string? DestinationId,
|
||||
bool IsWalking,
|
||||
bool ActivityActive,
|
||||
bool IsStudent,
|
||||
bool IsStaff,
|
||||
bool IsParent,
|
||||
bool BoundToLesson,
|
||||
string? DutyRoom,
|
||||
IReadOnlyDictionary<string, float> Needs,
|
||||
Intent Intent);
|
||||
|
||||
/// <summary>
|
||||
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
|
||||
/// output. Duty is a strong goal, not an order; a need at zero beats it, a need just under the
|
||||
/// threshold does not.
|
||||
/// </summary>
|
||||
public static class DecisionPlanner
|
||||
{
|
||||
/// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary>
|
||||
public const float DutyLessonWeight = 10f;
|
||||
|
||||
/// <summary>Walk to the next room on a break. Beats chatting in the corridor you are standing in.</summary>
|
||||
public const float DutyTravelWeight = 5f;
|
||||
|
||||
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
|
||||
public const float NeedWeightAtZero = 20f;
|
||||
|
||||
public static Decision Decide(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(map);
|
||||
ArgumentNullException.ThrowIfNull(walks);
|
||||
ArgumentNullException.ThrowIfNull(occupied);
|
||||
|
||||
var rules = catalog.BehaviorRules;
|
||||
var threshold = rules?.NeedThreshold ?? 0.35f;
|
||||
var margin = rules?.SwitchMargin ?? 0.15f;
|
||||
var best = PickGoal(catalog, map, walks, state, occupied, threshold);
|
||||
var held = HeldGoal(catalog, map, walks, state, occupied, threshold);
|
||||
if (held.IsSet && best.Weight <= held.Weight + margin && SameGoal(state.Intent, held))
|
||||
{
|
||||
return Continue(state with { Intent = held });
|
||||
}
|
||||
|
||||
return Plan(catalog, map, walks, state, occupied, best);
|
||||
}
|
||||
|
||||
private static Intent PickGoal(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
float threshold)
|
||||
{
|
||||
var best = Intent.None;
|
||||
Consider(ref best, DutyGoal(state));
|
||||
foreach (var need in catalog.Needs.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, threshold));
|
||||
}
|
||||
|
||||
if (best.Kind != GoalKind.Duty || best.Weight < DutyLessonWeight)
|
||||
{
|
||||
foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
Consider(ref best, LeisureGoal(catalog, map, walks, state, occupied, action));
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stored intent's weight under current inputs. A lesson that just ended is not still
|
||||
/// worth 10 — otherwise leisure can never beat a stale duty.
|
||||
/// </summary>
|
||||
private static Intent HeldGoal(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
float threshold)
|
||||
{
|
||||
if (!state.Intent.IsSet)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
switch (state.Intent.Kind)
|
||||
{
|
||||
case GoalKind.Duty:
|
||||
return DutyGoal(state);
|
||||
case GoalKind.Need:
|
||||
if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need))
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
return NeedGoal(catalog, map, walks, state, occupied, need, threshold);
|
||||
case GoalKind.Leisure:
|
||||
if (state.Intent.ActionId is null || !catalog.Actions.TryGetValue(state.Intent.ActionId, out var action))
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
return LeisureGoal(catalog, map, walks, state, occupied, action);
|
||||
default:
|
||||
return Intent.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static Intent DutyGoal(ActorState state)
|
||||
{
|
||||
if (state.DutyRoom is null)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
if (state.BoundToLesson)
|
||||
{
|
||||
return new Intent(GoalKind.Duty, state.DutyRoom, DutyLessonWeight, null);
|
||||
}
|
||||
|
||||
if (state.Intent.Kind == GoalKind.Leisure)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
if (state.NodeId is not null
|
||||
&& state.NodeId.Equals(state.DutyRoom, StringComparison.Ordinal)
|
||||
&& !state.IsWalking)
|
||||
{
|
||||
// Arrived this break (travel-weight duty). Stay put so a 3-weight chat does not
|
||||
// pull them out of the gym they just walked to. A leftover lesson intent (weight
|
||||
// 10) in the same room is the other case: the next lesson is here, leisure can win.
|
||||
if (state.Intent.Kind == GoalKind.Duty
|
||||
&& string.Equals(state.Intent.Id, state.DutyRoom, StringComparison.Ordinal)
|
||||
&& state.Intent.Weight <= DutyTravelWeight)
|
||||
{
|
||||
return new Intent(GoalKind.Duty, state.DutyRoom, DutyTravelWeight, null);
|
||||
}
|
||||
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
return new Intent(GoalKind.Duty, state.DutyRoom, DutyTravelWeight, null);
|
||||
}
|
||||
|
||||
private static Intent NeedGoal(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
NeedDef need,
|
||||
float threshold)
|
||||
{
|
||||
if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value) || value >= threshold)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
var action = ActionForNeed(catalog, state, need.DefName);
|
||||
if (action is null || RoomFor(catalog, map, walks, state, occupied, action) is null)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
var span = Math.Max(threshold, 0.0001f);
|
||||
var weight = (threshold - value) / span * NeedWeightAtZero;
|
||||
return new Intent(GoalKind.Need, need.DefName, weight, action.DefName);
|
||||
}
|
||||
|
||||
private static Intent LeisureGoal(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
ActionDef action)
|
||||
{
|
||||
if (action.Abstract || action.Weight <= 0 || !RoleFits(action, state))
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
if (RoomFor(catalog, map, walks, state, occupied, action) is null)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
return new Intent(GoalKind.Leisure, action.DefName, action.Weight, action.DefName);
|
||||
}
|
||||
|
||||
private static Decision Continue(ActorState state)
|
||||
{
|
||||
if (state.ActivityActive)
|
||||
{
|
||||
return Decision.Stay(state.Intent);
|
||||
}
|
||||
|
||||
if (state.Intent.ActionId is not null
|
||||
&& !state.IsWalking
|
||||
&& state.NodeId is not null
|
||||
&& (state.DestinationId is null || state.NodeId.Equals(state.DestinationId, StringComparison.Ordinal)))
|
||||
{
|
||||
return new Decision(null, state.Intent.ActionId, state.Intent);
|
||||
}
|
||||
|
||||
if (state.Intent.Kind == GoalKind.Duty
|
||||
&& state.NodeId is not null
|
||||
&& state.Intent.Id is not null
|
||||
&& state.NodeId.Equals(state.Intent.Id, StringComparison.Ordinal)
|
||||
&& !state.IsWalking)
|
||||
{
|
||||
return Decision.Stay(state.Intent);
|
||||
}
|
||||
|
||||
return new Decision(state.DestinationId ?? state.Intent.Id, null, state.Intent);
|
||||
}
|
||||
|
||||
private static Decision Plan(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
Intent goal)
|
||||
{
|
||||
if (!goal.IsSet)
|
||||
{
|
||||
return Decision.Stay(Intent.None);
|
||||
}
|
||||
|
||||
if (goal.Kind == GoalKind.Duty)
|
||||
{
|
||||
var room = goal.Id;
|
||||
if (room is null || (state.NodeId is not null && state.NodeId.Equals(room, StringComparison.Ordinal) && !state.IsWalking))
|
||||
{
|
||||
return Decision.Stay(goal);
|
||||
}
|
||||
|
||||
return new Decision(room, null, goal);
|
||||
}
|
||||
|
||||
if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var action))
|
||||
{
|
||||
return Decision.Stay(Intent.None);
|
||||
}
|
||||
|
||||
var node = RoomFor(catalog, map, walks, state, occupied, action);
|
||||
if (node is null)
|
||||
{
|
||||
return Decision.Stay(Intent.None);
|
||||
}
|
||||
|
||||
if (state.NodeId is not null && state.NodeId.Equals(node, StringComparison.Ordinal) && !state.IsWalking)
|
||||
{
|
||||
return new Decision(null, action.DefName, goal);
|
||||
}
|
||||
|
||||
return new Decision(node, null, goal);
|
||||
}
|
||||
|
||||
private static ActionDef? ActionForNeed(DefCatalog catalog, ActorState state, string need)
|
||||
{
|
||||
ActionDef? best = null;
|
||||
foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
|
||||
{
|
||||
if (action.Abstract
|
||||
|| !need.Equals(action.Need, StringComparison.Ordinal)
|
||||
|| !RoleFits(action, state))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best is null || action.NeedGain > best.NeedGain)
|
||||
{
|
||||
best = action;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static string? RoomFor(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
ActionDef action)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(action.Room))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? best = null;
|
||||
var bestCost = float.PositiveInfinity;
|
||||
var from = state.NodeId ?? walks.TerritoryId;
|
||||
foreach (var room in map.Rooms)
|
||||
{
|
||||
if (!action.Room.Equals(room.Def, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!HasSlot(catalog, map, occupied, room.Id, action.Thing))
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
{
|
||||
best = territory.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool HasSlot(DefCatalog catalog, MapLayout map, OccupiedCount occupied, string nodeId, string? thing)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(thing))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var available = RoomOccupancy.ThingCount(catalog, map, nodeId, thing);
|
||||
return ActionStepper.CanOccupy(available, occupied(nodeId, thing));
|
||||
}
|
||||
|
||||
private static bool RoleFits(ActionDef action, ActorState state)
|
||||
{
|
||||
if (action.Roles.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var role in action.Roles)
|
||||
{
|
||||
if (role.Equals(PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && state.IsStudent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (role.Equals(PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && state.IsStaff)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (role.Equals(PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && state.IsParent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SameGoal(Intent current, Intent held)
|
||||
{
|
||||
if (current.Kind != held.Kind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A new duty room (classroom → gym on the break) is not the same goal even at a similar
|
||||
// weight; keeping the old id would walk back to the lesson they just left.
|
||||
if (current.Kind == GoalKind.Duty)
|
||||
{
|
||||
return string.Equals(current.Id, held.Id, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Consider(ref Intent best, Intent candidate)
|
||||
{
|
||||
if (!candidate.IsSet)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!best.IsSet
|
||||
|| candidate.Weight > best.Weight
|
||||
|| (candidate.Weight == best.Weight && Order(candidate.Kind) < Order(best.Kind))
|
||||
|| (candidate.Weight == best.Weight
|
||||
&& candidate.Kind == best.Kind
|
||||
&& string.CompareOrdinal(candidate.Id, best.Id) < 0))
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
private static int Order(GoalKind kind) => kind switch
|
||||
{
|
||||
GoalKind.Duty => 0,
|
||||
GoalKind.Need => 1,
|
||||
GoalKind.Leisure => 2,
|
||||
_ => 3,
|
||||
};
|
||||
}
|
||||
|
||||
public delegate int OccupiedCount(string nodeId, string thing);
|
||||
@@ -0,0 +1,28 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// How much a lesson adds to one skill this step. Hungry learns worse; trait offsets scale the
|
||||
/// rate. The world stores the running total — this is just the number.
|
||||
/// </summary>
|
||||
public static class LessonLearning
|
||||
{
|
||||
public static float NeedFactor(float hunger) => Math.Clamp(0.25f + (0.75f * hunger), 0.25f, 1f);
|
||||
|
||||
public static float TraitFactor(int offset) => Math.Max(0.1f, 1f + (offset / 100f));
|
||||
|
||||
public static float Gain(
|
||||
float current,
|
||||
SkillDef skill,
|
||||
float share,
|
||||
float lessonSkillPerHour,
|
||||
float hours,
|
||||
float hunger,
|
||||
int traitOffset)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(skill);
|
||||
var delta = share * lessonSkillPerHour * hours * NeedFactor(hunger) * TraitFactor(traitOffset);
|
||||
return Math.Clamp(current + delta, skill.Range.Min, skill.Range.Max);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ describe('t', () => {
|
||||
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
|
||||
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
|
||||
.toBe('Класс 101 (18 · Математика · 5А)');
|
||||
expect(t('locationWalking', { name: 'Иванов' })).toBe('Иванов (walking)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ const ru = {
|
||||
presenceAt: '{name}',
|
||||
presenceWalking: 'в пути ({name})',
|
||||
presenceAway: 'вне школы',
|
||||
locationWalking: '{name} (в пути)',
|
||||
timetableTitle: 'Расписание',
|
||||
timetableClass: 'Класс',
|
||||
timetableEmpty: 'Нет уроков.',
|
||||
@@ -372,6 +373,7 @@ const en: Messages = {
|
||||
presenceAt: '{name}',
|
||||
presenceWalking: 'walking ({name})',
|
||||
presenceAway: 'off campus',
|
||||
locationWalking: '{name} (walking)',
|
||||
timetableTitle: 'Timetable',
|
||||
timetableClass: 'Class',
|
||||
timetableEmpty: 'No lessons.',
|
||||
|
||||
@@ -405,7 +405,10 @@ export class GameScreen {
|
||||
|
||||
const names = this.presence.people
|
||||
.filter((person) => person.nodeId === id)
|
||||
.map((person) => this.directory.get(person.id) ?? person.id);
|
||||
.map((person) => {
|
||||
const name = this.directory.get(person.id) ?? person.id;
|
||||
return person.state === PresenceState.Walking ? t('locationWalking', { name }) : name;
|
||||
});
|
||||
names.sort((left, right) => left.localeCompare(right));
|
||||
return names;
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ public sealed class BehaviorDef : Def
|
||||
/// <summary>A need at or below this value is urgent. Phase 21 turns that into a goal weight.</summary>
|
||||
public float NeedThreshold { get; init; }
|
||||
|
||||
/// <summary>Skill points a lesson adds per game hour, before traits and need state. Unused until phase 21.</summary>
|
||||
/// <summary>Skill points a lesson adds per game hour, before traits and need state.</summary>
|
||||
public float LessonSkillPerHour { get; init; }
|
||||
|
||||
/// <summary>Inclusive range of extra commute minutes rolled per person per day.</summary>
|
||||
|
||||
@@ -14,6 +14,9 @@ internal static class PersonCardReader
|
||||
private static readonly QueryDescription IdentityAndNeeds =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||
|
||||
private static readonly QueryDescription IdentityAndSkills =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonSkills>();
|
||||
|
||||
private static readonly QueryDescription IdentityAndActivity =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
|
||||
|
||||
@@ -45,6 +48,7 @@ internal static class PersonCardReader
|
||||
}
|
||||
|
||||
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
|
||||
var skills = LiveSkills(school.World, personId);
|
||||
var activityId = LiveActivity(school.World, personId);
|
||||
string? activityLabel = null;
|
||||
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
|
||||
@@ -72,7 +76,7 @@ internal static class PersonCardReader
|
||||
person.Position,
|
||||
PeopleListMapper.PositionLabel(catalog, locale, person.Position),
|
||||
Body(person, catalog, locale),
|
||||
Skills(person, catalog, locale),
|
||||
Skills(person, skills, catalog, locale),
|
||||
Traits(person, catalog, locale),
|
||||
Needs(needs, catalog, locale),
|
||||
activityId,
|
||||
@@ -93,6 +97,19 @@ internal static class PersonCardReader
|
||||
return found;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, float>? LiveSkills(World world, string personId)
|
||||
{
|
||||
Dictionary<string, float>? found = null;
|
||||
world.Query(in IdentityAndSkills, (ref PersonIdentity identity, ref PersonSkills skills) =>
|
||||
{
|
||||
if (identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
found = new Dictionary<string, float>(skills.Values, StringComparer.Ordinal);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
private static string? LiveActivity(World world, string personId)
|
||||
{
|
||||
string? found = null;
|
||||
@@ -153,10 +170,21 @@ internal static class PersonCardReader
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LabeledStatResponse> Skills(Person person, DefCatalog? catalog, string locale)
|
||||
private static IReadOnlyList<LabeledStatResponse> Skills(
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, float>? live,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
if (catalog is null)
|
||||
{
|
||||
if (live is not null)
|
||||
{
|
||||
return live
|
||||
.Select(pair => new LabeledStatResponse(pair.Key, pair.Key, FormatSkill(pair.Value)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return person.Skills
|
||||
.Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString()))
|
||||
.ToArray();
|
||||
@@ -165,7 +193,23 @@ internal static class PersonCardReader
|
||||
var rows = new List<LabeledStatResponse>();
|
||||
foreach (var def in catalog.Skills.Values)
|
||||
{
|
||||
if (def.Abstract || !person.Skills.TryGetValue(def.DefName, out var value))
|
||||
if (def.Abstract)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (live is not null)
|
||||
{
|
||||
if (!live.TryGetValue(def.DefName, out var liveValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), FormatSkill(liveValue)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!person.Skills.TryGetValue(def.DefName, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -176,6 +220,8 @@ internal static class PersonCardReader
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string FormatSkill(float value) => Math.Round(value, 2).ToString("0.##");
|
||||
|
||||
private static IReadOnlyList<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var rows = new List<DefLabelResponse>(person.Traits.Count);
|
||||
|
||||
@@ -69,17 +69,18 @@ internal static class ActivitySystem
|
||||
return started;
|
||||
}
|
||||
|
||||
public static void Apply(School school, double gameMinutes)
|
||||
public static IReadOnlyList<string> Apply(School school, double gameMinutes)
|
||||
{
|
||||
if (school.Catalog is null || gameMinutes <= 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
var catalog = school.Catalog;
|
||||
var minutes = (float)gameMinutes;
|
||||
var completed = new List<string>();
|
||||
var world = school.World;
|
||||
world.Query(in People, (ref PersonNeeds needs, ref Presence presence, ref PersonActivity activity) =>
|
||||
world.Query(in People, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence, ref PersonActivity activity) =>
|
||||
{
|
||||
if (!activity.IsActive)
|
||||
{
|
||||
@@ -92,11 +93,18 @@ internal static class ActivitySystem
|
||||
return;
|
||||
}
|
||||
|
||||
var location = school.Map?.NodeDef(presence.NodeId ?? "");
|
||||
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
return;
|
||||
}
|
||||
|
||||
var next = ActionStepper.Advance(
|
||||
new ActivityProgress(activity.ActionId, activity.Thing, activity.RemainingMinutes),
|
||||
minutes,
|
||||
out var completed);
|
||||
if (!completed)
|
||||
out var finished);
|
||||
if (!finished)
|
||||
{
|
||||
activity = new PersonActivity(next.ActionId, next.Thing, next.RemainingMinutes);
|
||||
return;
|
||||
@@ -110,7 +118,10 @@ internal static class ActivitySystem
|
||||
}
|
||||
|
||||
activity = PersonActivity.Idle;
|
||||
completed.Add(identity.Id);
|
||||
});
|
||||
completed.Sort(StringComparer.Ordinal);
|
||||
return completed;
|
||||
}
|
||||
|
||||
private static int Occupied(School school, string nodeId, string thing)
|
||||
|
||||
@@ -15,7 +15,7 @@ public readonly record struct PersonBody(
|
||||
IReadOnlyDictionary<string, int> Numbers,
|
||||
IReadOnlyDictionary<string, string> Choices);
|
||||
|
||||
public readonly record struct PersonSkills(IReadOnlyDictionary<string, int> Values);
|
||||
public readonly record struct PersonSkills(Dictionary<string, float> Values);
|
||||
|
||||
public readonly record struct PersonTraits(IReadOnlyList<string> Ids);
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Ai;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Schedule;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Grows skills for people who are actually in the lesson: at the room, not walking, not off
|
||||
/// doing something else. The formula lives in <see cref="HSchool.Ai.LessonLearning"/>.
|
||||
/// </summary>
|
||||
internal static class LessonLearningSystem
|
||||
{
|
||||
private static readonly QueryDescription People =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonSkills, PersonTraits, PersonNeeds, PersonRoles, Presence, PersonActivity>();
|
||||
|
||||
public static void Apply(School school, double gameMinutes)
|
||||
{
|
||||
if (school.Catalog is null || school.Timetable is null || school.Roster is null || gameMinutes <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rules = school.Catalog.BehaviorRules;
|
||||
if (rules is null || rules.LessonSkillPerHour <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
|
||||
if (slot.Kind != DaySlotKind.Lesson)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hours = (float)(gameMinutes / 60d);
|
||||
var weekday = SchoolDay.WeekdayIndex(school.Clock.Time);
|
||||
var catalog = school.Catalog;
|
||||
var world = school.World;
|
||||
world.Query(
|
||||
in People,
|
||||
(ref PersonIdentity identity, ref PersonSkills skills, ref PersonTraits traits, ref PersonNeeds needs, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) =>
|
||||
{
|
||||
if (activity.IsActive || presence.NodeId is null || presence.Path.Length > 0 || presence.RemainingMinutes > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var personId = identity.Id;
|
||||
var person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
|
||||
if (person is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var lesson = CurrentLesson(school, person, weekday, slot.Index);
|
||||
if (lesson is null
|
||||
|| !presence.NodeId.Equals(lesson.RoomId, StringComparison.Ordinal)
|
||||
|| !catalog.Subjects.TryGetValue(lesson.Subject, out var subject))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
|
||||
foreach (var share in subject.Skills)
|
||||
{
|
||||
if (!catalog.Skills.TryGetValue(share.Skill, out var skill)
|
||||
|| skill.Abstract
|
||||
|| !skills.Values.TryGetValue(share.Skill, out var current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.Values[share.Skill] = LessonLearning.Gain(
|
||||
current,
|
||||
skill,
|
||||
share.Share,
|
||||
rules.LessonSkillPerHour,
|
||||
hours,
|
||||
hunger,
|
||||
TraitOffset(catalog, traits, share.Skill));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static LessonPlacement? CurrentLesson(School school, Person person, int weekday, int period)
|
||||
{
|
||||
foreach (var lesson in Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday))
|
||||
{
|
||||
if (lesson.Period == period)
|
||||
{
|
||||
return lesson;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SchoolClass? ClassOf(School school, Person person)
|
||||
{
|
||||
if (person.ClassId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static int TraitOffset(DefCatalog catalog, PersonTraits traits, string skill)
|
||||
{
|
||||
var offset = 0;
|
||||
foreach (var name in traits.Ids)
|
||||
{
|
||||
if (!catalog.Traits.TryGetValue(name, out var trait))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var modifier in trait.SkillModifiers)
|
||||
{
|
||||
if (modifier.Skill.Equals(skill, StringComparison.Ordinal))
|
||||
{
|
||||
offset += modifier.Offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace HSchool.Simulation;
|
||||
internal static class PresenceSystem
|
||||
{
|
||||
private static readonly QueryDescription People =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, Presence, PersonActivity>();
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonNeeds, PersonTraits, Presence, PersonActivity, Intent>();
|
||||
|
||||
public static void Apply(School school, double gameMinutes)
|
||||
{
|
||||
@@ -23,13 +23,58 @@ internal static class PresenceSystem
|
||||
}
|
||||
|
||||
EnsurePlans(school);
|
||||
school.DecisionBudget = school.MaxDecisionsPerTick;
|
||||
EnqueueEvents(school);
|
||||
EnqueueTimeEvents(school, (float)gameMinutes);
|
||||
DrainDecisions(school);
|
||||
Move(school, (float)gameMinutes);
|
||||
FinishHome(school);
|
||||
DrainDecisions(school);
|
||||
}
|
||||
|
||||
public static IReadOnlySet<string> BelowThreshold(School school)
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (school.Catalog?.BehaviorRules is null)
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
var threshold = school.Catalog.BehaviorRules.NeedThreshold;
|
||||
var world = school.World;
|
||||
var query = new QueryDescription().WithAll<PersonIdentity, PersonNeeds, Presence>();
|
||||
world.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence) =>
|
||||
{
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var def in school.Catalog.Needs.Values)
|
||||
{
|
||||
if (!def.Abstract && needs.Values.TryGetValue(def.DefName, out var value) && value < threshold)
|
||||
{
|
||||
ids.Add(identity.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static void EnqueueNewlyUrgent(School school, IReadOnlySet<string> previouslyBelow)
|
||||
{
|
||||
foreach (var id in BelowThreshold(school))
|
||||
{
|
||||
if (!previouslyBelow.Contains(id))
|
||||
{
|
||||
school.DecisionQueue.Enqueue(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Enqueue(School school, string personId) => school.DecisionQueue.Enqueue(personId);
|
||||
|
||||
public static bool IsEmpty(School school)
|
||||
{
|
||||
var empty = true;
|
||||
@@ -48,7 +93,7 @@ internal static class PresenceSystem
|
||||
{
|
||||
var rows = new List<PresenceSnapshot>();
|
||||
var world = school.World;
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
rows.Add(new PresenceSnapshot(
|
||||
identity.Id,
|
||||
@@ -59,7 +104,11 @@ internal static class PresenceSystem
|
||||
presence.Path,
|
||||
activity.ActionId,
|
||||
activity.Thing,
|
||||
activity.RemainingMinutes));
|
||||
activity.RemainingMinutes,
|
||||
intent.Kind.ToString(),
|
||||
intent.Id,
|
||||
intent.Weight,
|
||||
intent.ActionId));
|
||||
});
|
||||
rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
|
||||
return rows;
|
||||
@@ -77,12 +126,13 @@ internal static class PresenceSystem
|
||||
var byId = saved
|
||||
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
|
||||
.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
|
||||
ForEachPerson(school, (person, _, ref presence, ref activity) =>
|
||||
ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) =>
|
||||
{
|
||||
if (!byId.TryGetValue(person.Id, out var row))
|
||||
{
|
||||
presence = PlaceByDuty(school, person);
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,6 +140,7 @@ internal static class PresenceSystem
|
||||
{
|
||||
presence = Presence.OffCampus;
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,21 +153,33 @@ internal static class PresenceSystem
|
||||
activity = string.IsNullOrWhiteSpace(row.ActionId)
|
||||
? PersonActivity.Idle
|
||||
: new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining);
|
||||
intent = ParseIntent(row);
|
||||
});
|
||||
}
|
||||
|
||||
public static void PlaceMissingByDuty(School school)
|
||||
{
|
||||
ForEachPerson(school, (person, _, ref presence, ref activity) =>
|
||||
ForEachPerson(school, (person, _, ref presence, ref activity, ref intent) =>
|
||||
{
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
presence = PlaceByDuty(school, person);
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void DrainDecisions(School school)
|
||||
{
|
||||
while (school.DecisionBudget > 0 && school.DecisionQueue.Count > 0)
|
||||
{
|
||||
var id = school.DecisionQueue.Dequeue();
|
||||
Decide(school, id);
|
||||
school.DecisionBudget--;
|
||||
}
|
||||
}
|
||||
|
||||
private static Presence PlaceByDuty(School school, Person person)
|
||||
{
|
||||
var room = Duty.RoomAt(
|
||||
@@ -207,17 +270,6 @@ internal static class PresenceSystem
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrainDecisions(School school)
|
||||
{
|
||||
var budget = school.MaxDecisionsPerTick;
|
||||
while (budget > 0 && school.DecisionQueue.Count > 0)
|
||||
{
|
||||
var id = school.DecisionQueue.Dequeue();
|
||||
Decide(school, id);
|
||||
budget--;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Decide(School school, string personId)
|
||||
{
|
||||
var person = school.Roster!.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
|
||||
@@ -226,26 +278,55 @@ internal static class PresenceSystem
|
||||
return;
|
||||
}
|
||||
|
||||
var occupied = SnapshotOccupied(school, personId);
|
||||
string? startAction = null;
|
||||
var world = school.World;
|
||||
var found = false;
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
|
||||
{
|
||||
if (found || !identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
world.Query(
|
||||
in People,
|
||||
(ref PersonIdentity identity, ref PersonRoles roles, ref PersonNeeds needs, ref PersonTraits traits, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (found || !identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
found = true;
|
||||
presence = NextPresence(school, person, plan, presence);
|
||||
});
|
||||
found = true;
|
||||
_ = traits;
|
||||
startAction = ApplyDecision(
|
||||
school,
|
||||
person,
|
||||
plan,
|
||||
occupied,
|
||||
ref presence,
|
||||
ref activity,
|
||||
ref intent,
|
||||
roles,
|
||||
needs);
|
||||
});
|
||||
|
||||
if (startAction is not null)
|
||||
{
|
||||
ActivitySystem.TryStart(school, person.Id, startAction);
|
||||
}
|
||||
}
|
||||
|
||||
private static Presence NextPresence(School school, Person person, DayPlan plan, Presence presence)
|
||||
private static string? ApplyDecision(
|
||||
School school,
|
||||
Person person,
|
||||
DayPlan plan,
|
||||
Dictionary<(string Node, string Thing), int> occupied,
|
||||
ref Presence presence,
|
||||
ref PersonActivity activity,
|
||||
ref Intent intent,
|
||||
PersonRoles roles,
|
||||
PersonNeeds needs)
|
||||
{
|
||||
var now = school.Clock.Time;
|
||||
var walks = school.Walks!;
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
intent = Intent.None;
|
||||
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
|
||||
{
|
||||
var dest = plan.FirstRoom ?? Duty.RoomAt(
|
||||
@@ -255,15 +336,22 @@ internal static class PresenceSystem
|
||||
school.Catalog!,
|
||||
now,
|
||||
school.SchoolWeekDays);
|
||||
return dest is null ? Presence.OffCampus : PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
|
||||
presence = dest is null
|
||||
? Presence.OffCampus
|
||||
: PresenceStepper.StartWalk(Presence.OffCampus, walks, dest, headingHome: false);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Presence.OffCampus;
|
||||
presence = Presence.OffCampus;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.WalkHomeAt is { } leave && now >= leave)
|
||||
{
|
||||
return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
var duty = Duty.RoomAt(
|
||||
@@ -275,15 +363,71 @@ internal static class PresenceSystem
|
||||
school.SchoolWeekDays);
|
||||
if (duty is null)
|
||||
{
|
||||
return PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
presence = PresenceStepper.StartWalk(presence, walks, walks.TerritoryId, headingHome: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presence.HeadingHome || !duty.Equals(presence.DestinationId, StringComparison.Ordinal))
|
||||
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 state = new ActorState(
|
||||
presence.NodeId,
|
||||
presence.DestinationId,
|
||||
presence.Path.Length > 0 || presence.RemainingMinutes > 0,
|
||||
activity.IsActive,
|
||||
roles.IsStudent,
|
||||
roles.IsStaff,
|
||||
roles.IsParent,
|
||||
bound,
|
||||
duty,
|
||||
needs.Values,
|
||||
intent);
|
||||
var decision = DecisionPlanner.Decide(
|
||||
school.Catalog!,
|
||||
school.Map!,
|
||||
walks,
|
||||
state,
|
||||
(node, thing) => occupied.GetValueOrDefault((node, thing)));
|
||||
|
||||
if (decision.WalkTo is not null
|
||||
&& !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal)
|
||||
&& activity.IsActive)
|
||||
{
|
||||
return PresenceStepper.StartWalk(presence, walks, duty, headingHome: false);
|
||||
activity = PersonActivity.Idle;
|
||||
}
|
||||
|
||||
return presence;
|
||||
intent = decision.Intent;
|
||||
if (decision.WalkTo is not null)
|
||||
{
|
||||
presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false);
|
||||
}
|
||||
|
||||
return decision.StartAction is not null && !activity.IsActive ? decision.StartAction : null;
|
||||
}
|
||||
|
||||
private static Dictionary<(string Node, string Thing), int> SnapshotOccupied(School school, string exceptId)
|
||||
{
|
||||
var occupied = new Dictionary<(string Node, string Thing), int>();
|
||||
var world = school.World;
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
|
||||
{
|
||||
if (identity.Id.Equals(exceptId, StringComparison.Ordinal)
|
||||
|| !activity.IsActive
|
||||
|| presence.NodeId is null
|
||||
|| activity.Thing is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = (presence.NodeId, activity.Thing);
|
||||
occupied[key] = occupied.GetValueOrDefault(key) + 1;
|
||||
});
|
||||
return occupied;
|
||||
}
|
||||
|
||||
private static void Move(School school, float minutes)
|
||||
@@ -295,13 +439,15 @@ internal static class PresenceSystem
|
||||
|
||||
var walks = school.Walks;
|
||||
var world = school.World;
|
||||
world.Query(in People, (ref Presence presence) =>
|
||||
var arrived = new List<string>();
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
|
||||
{
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var travelling = presence.Path.Length > 0 || presence.RemainingMinutes > 0;
|
||||
var remaining = presence.RemainingMinutes - minutes;
|
||||
var node = presence.NodeId!;
|
||||
var path = presence.Path;
|
||||
@@ -320,7 +466,16 @@ internal static class PresenceSystem
|
||||
|
||||
var leftover = index >= path.Length ? [] : path[index..];
|
||||
presence = presence with { NodeId = node, RemainingMinutes = remaining, Path = leftover };
|
||||
if (travelling && leftover.Length == 0 && remaining <= 0)
|
||||
{
|
||||
arrived.Add(identity.Id);
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var id in arrived.OrderBy(value => value, StringComparer.Ordinal))
|
||||
{
|
||||
school.DecisionQueue.Enqueue(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void FinishHome(School school)
|
||||
@@ -332,7 +487,7 @@ internal static class PresenceSystem
|
||||
}
|
||||
|
||||
var world = school.World;
|
||||
world.Query(in People, (ref Presence presence, ref PersonActivity activity) =>
|
||||
world.Query(in People, (ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
if (presence.HeadingHome
|
||||
&& presence.NodeId is not null
|
||||
@@ -342,10 +497,23 @@ internal static class PresenceSystem
|
||||
{
|
||||
presence = Presence.OffCampus;
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Intent ParseIntent(PresenceSnapshot row)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.GoalKind)
|
||||
|| !Enum.TryParse<GoalKind>(row.GoalKind, out var kind)
|
||||
|| kind == GoalKind.None)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
return new Intent(kind, row.GoalId, row.GoalWeight, row.GoalAction);
|
||||
}
|
||||
|
||||
private static SchoolClass? ClassOf(School school, Person person)
|
||||
{
|
||||
if (person.ClassId is null)
|
||||
@@ -359,17 +527,22 @@ internal static class PresenceSystem
|
||||
private static IReadOnlyList<Person> OrderedPeople(School school) =>
|
||||
school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
|
||||
|
||||
private delegate void PersonAction(Person person, PersonIdentity identity, ref Presence presence, ref PersonActivity activity);
|
||||
private delegate void PersonAction(
|
||||
Person person,
|
||||
PersonIdentity identity,
|
||||
ref Presence presence,
|
||||
ref PersonActivity activity,
|
||||
ref Intent intent);
|
||||
|
||||
private static void ForEachPerson(School school, PersonAction action)
|
||||
{
|
||||
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||
var world = school.World;
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
|
||||
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
if (roster.TryGetValue(identity.Id, out var person))
|
||||
{
|
||||
action(person, identity, ref presence, ref activity);
|
||||
action(person, identity, ref presence, ref activity, ref intent);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -384,4 +557,8 @@ public sealed record PresenceSnapshot(
|
||||
IReadOnlyList<string> Path,
|
||||
string? ActionId = null,
|
||||
string? ActionThing = null,
|
||||
float ActionRemaining = 0f);
|
||||
float ActionRemaining = 0f,
|
||||
string? GoalKind = null,
|
||||
string? GoalId = null,
|
||||
float GoalWeight = 0f,
|
||||
string? GoalAction = null);
|
||||
|
||||
@@ -28,7 +28,7 @@ public static class RosterSpawner
|
||||
world.Create(
|
||||
new PersonIdentity(person.Id, person.FamilyId, person.Female, person.BirthDate, person.Name),
|
||||
new PersonBody(person.Numbers, person.Choices),
|
||||
new PersonSkills(person.Skills),
|
||||
new PersonSkills(person.Skills.ToDictionary(pair => pair.Key, pair => (float)pair.Value, StringComparer.Ordinal)),
|
||||
new PersonTraits(person.Traits),
|
||||
new PersonNeeds(new Dictionary<string, float>(person.Needs, StringComparer.Ordinal)),
|
||||
new PersonRoles(
|
||||
@@ -39,7 +39,8 @@ public static class RosterSpawner
|
||||
person.Position,
|
||||
person.WorkplaceRoomId),
|
||||
Presence.OffCampus,
|
||||
PersonActivity.Idle);
|
||||
PersonActivity.Idle,
|
||||
Intent.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,17 @@ public sealed class School : IDisposable
|
||||
|
||||
internal Queue<string> DecisionQueue { get; } = new();
|
||||
|
||||
internal int DecisionBudget { get; set; }
|
||||
|
||||
public int PendingDecisionCount => DecisionQueue.Count;
|
||||
|
||||
public void QueueDecision(string personId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
|
||||
PresenceSystem.Enqueue(this, personId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
||||
/// </summary>
|
||||
@@ -243,10 +254,18 @@ public sealed class School : IDisposable
|
||||
}
|
||||
|
||||
PresenceSystem.Apply(this, gameMinutes);
|
||||
ActivitySystem.Apply(this, gameMinutes);
|
||||
foreach (var id in ActivitySystem.Apply(this, gameMinutes))
|
||||
{
|
||||
PresenceSystem.Enqueue(this, id);
|
||||
}
|
||||
|
||||
if (Catalog is not null)
|
||||
{
|
||||
var below = PresenceSystem.BelowThreshold(this);
|
||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||
PresenceSystem.EnqueueNewlyUrgent(this, below);
|
||||
PresenceSystem.DrainDecisions(this);
|
||||
LessonLearningSystem.Apply(this, gameMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user