Update decision-making phase and enhance localization for presence tracking
ci / server (push) Failing after 3m40s
ci / client (push) Successful in 14s

- 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:
Leonid Pershin
2026-08-19 21:00:12 +03:00
parent b135a9caad
commit 5cd5a6d258
21 changed files with 1839 additions and 72 deletions
+462
View File
@@ -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);
+28
View File
@@ -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);
}
}