Split SchoolWorker and the person card along existing seams without a second thread or public API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 13:51:39 +03:00
co-authored by Cursor
parent 8d07b3606d
commit b48bbbcf7c
18 changed files with 2307 additions and 2193 deletions
@@ -0,0 +1,196 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Server.Api;
namespace HSchool.Server.Game;
internal static partial class PersonCardReader
{
private static PersonConnectionsResponse Connections(
Roster roster,
Person person,
DefCatalog? catalog,
string locale)
{
var familyIds = OpinionStore.FamilyMemberIds(roster, person);
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var family = FamilyWithOpinions(roster, person, people, catalog, locale);
var others = new List<PersonOpinionLinkResponse>();
foreach (var (targetId, value) in person.Opinions)
{
if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target))
{
continue;
}
others.Add(Link(target, value, catalog, locale));
}
others.Sort((left, right) =>
{
var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion));
return byAbs != 0
? byAbs
: string.Compare(left.FullName, right.FullName, StringComparison.Ordinal);
});
var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5;
var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray();
var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray();
var crushes = CrushesOf(person, people, catalog, locale);
var admirers = AdmirersOf(roster, person, people, catalog, locale);
var pair = PairOf(person, people, catalog, locale);
return new PersonConnectionsResponse(family, friends, enemies, others, crushes, admirers, pair);
}
private static DefLabelResponse? OrientationOf(Person person, DefCatalog? catalog, string locale)
{
if (person.Orientation is null)
{
return null;
}
var label = person.Orientation;
if (catalog is not null && catalog.Orientations.TryGetValue(person.Orientation, out var def))
{
label = catalog.Label(locale, def);
}
return new DefLabelResponse(person.Orientation, label);
}
private static IReadOnlyList<PersonOpinionLinkResponse> CrushesOf(
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
if (person.Bonds is null)
{
return [];
}
var rows = new List<PersonOpinionLinkResponse>();
foreach (var id in person.Bonds.Crushes)
{
if (!people.TryGetValue(id, out var target))
{
continue;
}
var opinion = OpinionStore.Get(person, id) ?? 0;
rows.Add(Link(target, opinion, catalog, locale));
}
return rows;
}
private static IReadOnlyList<PersonOpinionLinkResponse> AdmirersOf(
Roster roster,
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
var rows = new List<PersonOpinionLinkResponse>();
foreach (var other in roster.People)
{
if (other.Id.Equals(person.Id, StringComparison.Ordinal) || other.Bonds is null)
{
continue;
}
if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal))
{
continue;
}
var opinion = OpinionStore.Get(other, person.Id) ?? 0;
rows.Add(Link(other, opinion, catalog, locale));
}
return rows;
}
private static PersonRelResponse? PairOf(
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
if (person.Bonds?.PartnerId is not { } partnerId || !people.TryGetValue(partnerId, out var partner))
{
return null;
}
int? opinion = null;
string? label = null;
if (person.Opinions.TryGetValue(partnerId, out var value))
{
opinion = value;
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
}
return new PersonRelResponse(partner.Id, partner.Name.Full, partner.Female, opinion, label);
}
private static PersonFamilyResponse FamilyWithOpinions(
Roster roster,
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return new PersonFamilyResponse([], [], [], []);
}
var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse(
inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [],
inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []);
}
private static IReadOnlyList<PersonRelResponse> RelativesWithOpinions(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
Person person,
string except,
DefCatalog? catalog,
string locale)
{
var rows = new List<PersonRelResponse>();
foreach (var id in ids)
{
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
{
continue;
}
int? opinion = null;
string? label = null;
if (person.Opinions.TryGetValue(id, out var value))
{
opinion = value;
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
}
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label));
}
return rows;
}
private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) =>
new(
target.Id,
target.Name.Full,
target.Female,
opinion,
catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion));
}
@@ -0,0 +1,109 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Server.Api;
using HSchool.Simulation;
namespace HSchool.Server.Game;
internal static partial class PersonCardReader
{
private static IReadOnlyList<WornItemResponse> Worn(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<WornItemResponse>();
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
{
continue;
}
IReadOnlyList<string> layerIds = [];
IReadOnlyList<string> fullyCovers = [];
if (catalog is not null && catalog.Things.TryGetValue(item.Def, out var def))
{
layerIds = def.Layers;
fullyCovers = def.FullyCoversLayers;
}
var layers = layerIds
.Select(layer => new DefLabelResponse(layer, catalog?.Text(locale, layer) ?? layer))
.ToArray();
rows.Add(new WornItemResponse(
item.Def,
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
layers,
item.Condition,
catalog is null
? ApparelCondition.BandId(null, item.Condition)
: ApparelCondition.Label(catalog, locale, item.Condition),
fullyCovers));
}
return rows;
}
private static IReadOnlyList<CarriedItemResponse> Carried(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<CarriedItemResponse>();
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
{
continue;
}
var mass = catalog is not null && catalog.Things.TryGetValue(item.Def, out var def) ? def.Mass : 0f;
string? subjectLabel = null;
if (item.Subject is not null)
{
subjectLabel = catalog is not null && catalog.Subjects.TryGetValue(item.Subject, out var subject)
? catalog.Label(locale, subject)
: item.Subject;
}
rows.Add(new CarriedItemResponse(
item.Def,
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
item.Subject,
subjectLabel,
mass));
}
return rows;
}
private static string ThingLabel(DefCatalog? catalog, string locale, string defName)
{
if (catalog is not null && catalog.Things.TryGetValue(defName, out var def))
{
return catalog.Label(locale, def);
}
return defName;
}
private static string? ColorLabel(DefCatalog? catalog, string locale, string? color) =>
color is null ? null : catalog?.Text(locale, color) ?? color;
private static float Capacity(
Person person,
IReadOnlyDictionary<string, float>? live,
DefCatalog? catalog)
{
if (catalog is null)
{
return 0f;
}
if (live is not null)
{
return CarryMass.Capacity(catalog, live);
}
return CarryMass.Capacity(catalog, person.Skills);
}
}
@@ -0,0 +1,194 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Server.Api;
namespace HSchool.Server.Game;
internal static partial class PersonCardReader
{
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<LabeledStatResponse>();
if (catalog is not null)
{
foreach (var def in catalog.BodyAttributes.Values)
{
if (def.Abstract)
{
continue;
}
if (def.Kind == BodyAttributeKind.Number && person.Numbers.TryGetValue(def.DefName, out var number))
{
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), number.ToString()));
}
else if (def.Kind == BodyAttributeKind.Choice && person.Choices.TryGetValue(def.DefName, out var choice))
{
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), catalog.Text(locale, choice)));
}
}
}
else
{
foreach (var (id, number) in person.Numbers)
{
rows.Add(new LabeledStatResponse(id, id, number.ToString()));
}
foreach (var (id, choice) in person.Choices)
{
rows.Add(new LabeledStatResponse(id, id, choice));
}
}
if (person.Choices.TryGetValue(BodyBuilds.Attribute, out var build)
&& rows.TrueForAll(row => row.Id != BodyBuilds.Attribute))
{
var label = catalog?.Text(locale, BodyBuilds.Attribute) ?? BodyBuilds.Attribute;
var value = catalog?.Text(locale, build) ?? build;
rows.Add(new LabeledStatResponse(BodyBuilds.Attribute, label, value));
}
return rows;
}
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();
}
var rows = new List<LabeledStatResponse>();
foreach (var def in catalog.Skills.Values)
{
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;
}
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), value.ToString()));
}
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);
foreach (var id in person.Traits)
{
var label = catalog is not null && catalog.Traits.TryGetValue(id, out var def)
? catalog.Label(locale, def)
: id;
rows.Add(new DefLabelResponse(id, label));
}
return rows;
}
private static IReadOnlyList<NeedStatResponse> Needs(
IReadOnlyDictionary<string, float> values,
DefCatalog? catalog,
string locale)
{
if (catalog is null)
{
return values.Select(pair => new NeedStatResponse(pair.Key, pair.Key, pair.Value)).ToArray();
}
var rows = new List<NeedStatResponse>();
foreach (var def in catalog.Needs.Values)
{
if (def.Abstract || !values.TryGetValue(def.DefName, out var value))
{
continue;
}
rows.Add(new NeedStatResponse(def.DefName, catalog.Label(locale, def), value));
}
return rows;
}
private static PersonFamilyResponse Family(Roster roster, Person person)
{
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return new PersonFamilyResponse([], [], [], []);
}
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse(
inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [],
inParents ? Relatives(family.ChildIds, people, except: person.Id) : [],
inChildren ? Relatives(family.ChildIds, people, except: person.Id) : [],
inParents ? Relatives(family.ParentIds, people, except: person.Id) : []);
}
private static bool InFamily(IReadOnlyList<string> ids, string id)
{
foreach (var candidate in ids)
{
if (candidate.Equals(id, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static IReadOnlyList<PersonRelResponse> Relatives(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
string except)
{
var rows = new List<PersonRelResponse>();
foreach (var id in ids)
{
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
{
continue;
}
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female));
}
return rows;
}
}
+1 -475
View File
@@ -9,7 +9,7 @@ namespace HSchool.Server.Game;
/// <summary>
/// Builds a person card on the school's worker thread so live need values come from the World.
/// </summary>
internal static class PersonCardReader
internal static partial class PersonCardReader
{
private static readonly QueryDescription IdentityAndNeeds =
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
@@ -136,478 +136,4 @@ internal static class PersonCardReader
});
return found;
}
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<LabeledStatResponse>();
if (catalog is not null)
{
foreach (var def in catalog.BodyAttributes.Values)
{
if (def.Abstract)
{
continue;
}
if (def.Kind == BodyAttributeKind.Number && person.Numbers.TryGetValue(def.DefName, out var number))
{
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), number.ToString()));
}
else if (def.Kind == BodyAttributeKind.Choice && person.Choices.TryGetValue(def.DefName, out var choice))
{
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), catalog.Text(locale, choice)));
}
}
}
else
{
foreach (var (id, number) in person.Numbers)
{
rows.Add(new LabeledStatResponse(id, id, number.ToString()));
}
foreach (var (id, choice) in person.Choices)
{
rows.Add(new LabeledStatResponse(id, id, choice));
}
}
if (person.Choices.TryGetValue(BodyBuilds.Attribute, out var build)
&& rows.TrueForAll(row => row.Id != BodyBuilds.Attribute))
{
var label = catalog?.Text(locale, BodyBuilds.Attribute) ?? BodyBuilds.Attribute;
var value = catalog?.Text(locale, build) ?? build;
rows.Add(new LabeledStatResponse(BodyBuilds.Attribute, label, value));
}
return rows;
}
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();
}
var rows = new List<LabeledStatResponse>();
foreach (var def in catalog.Skills.Values)
{
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;
}
rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), value.ToString()));
}
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);
foreach (var id in person.Traits)
{
var label = catalog is not null && catalog.Traits.TryGetValue(id, out var def)
? catalog.Label(locale, def)
: id;
rows.Add(new DefLabelResponse(id, label));
}
return rows;
}
private static IReadOnlyList<NeedStatResponse> Needs(
IReadOnlyDictionary<string, float> values,
DefCatalog? catalog,
string locale)
{
if (catalog is null)
{
return values.Select(pair => new NeedStatResponse(pair.Key, pair.Key, pair.Value)).ToArray();
}
var rows = new List<NeedStatResponse>();
foreach (var def in catalog.Needs.Values)
{
if (def.Abstract || !values.TryGetValue(def.DefName, out var value))
{
continue;
}
rows.Add(new NeedStatResponse(def.DefName, catalog.Label(locale, def), value));
}
return rows;
}
private static IReadOnlyList<WornItemResponse> Worn(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<WornItemResponse>();
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
{
continue;
}
IReadOnlyList<string> layerIds = [];
IReadOnlyList<string> fullyCovers = [];
if (catalog is not null && catalog.Things.TryGetValue(item.Def, out var def))
{
layerIds = def.Layers;
fullyCovers = def.FullyCoversLayers;
}
var layers = layerIds
.Select(layer => new DefLabelResponse(layer, catalog?.Text(locale, layer) ?? layer))
.ToArray();
rows.Add(new WornItemResponse(
item.Def,
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
layers,
item.Condition,
catalog is null
? ApparelCondition.BandId(null, item.Condition)
: ApparelCondition.Label(catalog, locale, item.Condition),
fullyCovers));
}
return rows;
}
private static IReadOnlyList<CarriedItemResponse> Carried(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<CarriedItemResponse>();
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
{
continue;
}
var mass = catalog is not null && catalog.Things.TryGetValue(item.Def, out var def) ? def.Mass : 0f;
string? subjectLabel = null;
if (item.Subject is not null)
{
subjectLabel = catalog is not null && catalog.Subjects.TryGetValue(item.Subject, out var subject)
? catalog.Label(locale, subject)
: item.Subject;
}
rows.Add(new CarriedItemResponse(
item.Def,
ThingLabel(catalog, locale, item.Def),
item.Color,
ColorLabel(catalog, locale, item.Color),
item.Subject,
subjectLabel,
mass));
}
return rows;
}
private static string ThingLabel(DefCatalog? catalog, string locale, string defName)
{
if (catalog is not null && catalog.Things.TryGetValue(defName, out var def))
{
return catalog.Label(locale, def);
}
return defName;
}
private static string? ColorLabel(DefCatalog? catalog, string locale, string? color) =>
color is null ? null : catalog?.Text(locale, color) ?? color;
private static float Capacity(
Person person,
IReadOnlyDictionary<string, float>? live,
DefCatalog? catalog)
{
if (catalog is null)
{
return 0f;
}
if (live is not null)
{
return CarryMass.Capacity(catalog, live);
}
return CarryMass.Capacity(catalog, person.Skills);
}
private static PersonFamilyResponse Family(Roster roster, Person person)
{
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return new PersonFamilyResponse([], [], [], []);
}
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse(
inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [],
inParents ? Relatives(family.ChildIds, people, except: person.Id) : [],
inChildren ? Relatives(family.ChildIds, people, except: person.Id) : [],
inParents ? Relatives(family.ParentIds, people, except: person.Id) : []);
}
private static bool InFamily(IReadOnlyList<string> ids, string id)
{
foreach (var candidate in ids)
{
if (candidate.Equals(id, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static IReadOnlyList<PersonRelResponse> Relatives(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
string except)
{
var rows = new List<PersonRelResponse>();
foreach (var id in ids)
{
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
{
continue;
}
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female));
}
return rows;
}
private static PersonConnectionsResponse Connections(
Roster roster,
Person person,
DefCatalog? catalog,
string locale)
{
var familyIds = OpinionStore.FamilyMemberIds(roster, person);
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var family = FamilyWithOpinions(roster, person, people, catalog, locale);
var others = new List<PersonOpinionLinkResponse>();
foreach (var (targetId, value) in person.Opinions)
{
if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target))
{
continue;
}
others.Add(Link(target, value, catalog, locale));
}
others.Sort((left, right) =>
{
var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion));
return byAbs != 0
? byAbs
: string.Compare(left.FullName, right.FullName, StringComparison.Ordinal);
});
var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5;
var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray();
var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray();
var crushes = CrushesOf(person, people, catalog, locale);
var admirers = AdmirersOf(roster, person, people, catalog, locale);
var pair = PairOf(person, people, catalog, locale);
return new PersonConnectionsResponse(family, friends, enemies, others, crushes, admirers, pair);
}
private static DefLabelResponse? OrientationOf(Person person, DefCatalog? catalog, string locale)
{
if (person.Orientation is null)
{
return null;
}
var label = person.Orientation;
if (catalog is not null && catalog.Orientations.TryGetValue(person.Orientation, out var def))
{
label = catalog.Label(locale, def);
}
return new DefLabelResponse(person.Orientation, label);
}
private static IReadOnlyList<PersonOpinionLinkResponse> CrushesOf(
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
if (person.Bonds is null)
{
return [];
}
var rows = new List<PersonOpinionLinkResponse>();
foreach (var id in person.Bonds.Crushes)
{
if (!people.TryGetValue(id, out var target))
{
continue;
}
var opinion = OpinionStore.Get(person, id) ?? 0;
rows.Add(Link(target, opinion, catalog, locale));
}
return rows;
}
private static IReadOnlyList<PersonOpinionLinkResponse> AdmirersOf(
Roster roster,
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
var rows = new List<PersonOpinionLinkResponse>();
foreach (var other in roster.People)
{
if (other.Id.Equals(person.Id, StringComparison.Ordinal) || other.Bonds is null)
{
continue;
}
if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal))
{
continue;
}
var opinion = OpinionStore.Get(other, person.Id) ?? 0;
rows.Add(Link(other, opinion, catalog, locale));
}
return rows;
}
private static PersonRelResponse? PairOf(
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
if (person.Bonds?.PartnerId is not { } partnerId || !people.TryGetValue(partnerId, out var partner))
{
return null;
}
int? opinion = null;
string? label = null;
if (person.Opinions.TryGetValue(partnerId, out var value))
{
opinion = value;
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
}
return new PersonRelResponse(partner.Id, partner.Name.Full, partner.Female, opinion, label);
}
private static PersonFamilyResponse FamilyWithOpinions(
Roster roster,
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return new PersonFamilyResponse([], [], [], []);
}
var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse(
inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [],
inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []);
}
private static IReadOnlyList<PersonRelResponse> RelativesWithOpinions(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
Person person,
string except,
DefCatalog? catalog,
string locale)
{
var rows = new List<PersonRelResponse>();
foreach (var id in ids)
{
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
{
continue;
}
int? opinion = null;
string? label = null;
if (person.Opinions.TryGetValue(id, out var value))
{
opinion = value;
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
}
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label));
}
return rows;
}
private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) =>
new(
target.Id,
target.Name.Full,
target.Female,
opinion,
catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion));
}
@@ -0,0 +1,344 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
using HSchool.Server.Api;
using HSchool.Simulation;
namespace HSchool.Server.Game;
internal sealed partial class SchoolWorker
{
private void DrainMailbox()
{
var school = _school;
if (school is null)
{
while (_mailbox.Reader.TryRead(out var orphan))
{
CompleteOrphan(orphan);
}
return;
}
var dirty = false;
while (_mailbox.Reader.TryRead(out var command))
{
// Every command here was triggered by a browser. One of them failing — an oversized
// snapshot, a client that vanished mid-send — must cost that command, not the school.
try
{
switch (command)
{
case WorkerCommand.Open open:
open.Client.OpenSchoolId = _id;
SendMapSnapshot(open.Client, school);
BroadcastClockTo(open.Client, school);
SendPresence(open.Client, school);
break;
case WorkerCommand.Close close:
var leaving = _clients.Find(close.PlayerId);
if (leaving?.OpenSchoolId == _id)
{
leaving.OpenSchoolId = null;
}
break;
case WorkerCommand.SetRunning setRunning:
school.Clock.IsRunning = setRunning.Running;
dirty = true;
break;
case WorkerCommand.SetSpeed setSpeed:
school.Clock.SpeedIndex = setSpeed.SpeedIndex;
dirty = true;
break;
case WorkerCommand.SkipEmpty:
ApplySkip(school);
break;
case WorkerCommand.Dump dump:
dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays));
break;
case WorkerCommand.GetPerson getPerson:
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
getPerson.Result.TrySetResult(
card is null
? new PersonCardResult(null, PersonLookupError.UnknownPerson)
: new PersonCardResult(card, PersonLookupError.None));
break;
case WorkerCommand.GetPersonLog getLog:
var log = PersonLogReader.Read(school, getLog.PersonId, getLog.Query, getLog.Locale);
getLog.Result.TrySetResult(
log is null
? new PersonLogResult(null, PersonLookupError.UnknownPerson)
: new PersonLogResult(log, PersonLookupError.None));
break;
case WorkerCommand.HireStaff hire:
hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position));
break;
case WorkerCommand.AssignSubject assign:
assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject));
break;
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(
ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period));
break;
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
break;
case WorkerCommand.GetDressRules getRules:
getRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
break;
case WorkerCommand.SetDressRules setRules:
{
var next = school.DressRules;
if (setRules.PendingStudents is { } students)
{
next = next with { PendingStudents = students };
}
if (setRules.PendingStaff is { } staff)
{
next = next with { PendingStaff = staff };
}
school.DressRules = next;
setRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
dirty = true;
break;
}
}
}
catch (Exception ex)
{
FailCommand(command, ex);
_logger.LogError(
ex,
"Command {Command} failed for school {SchoolId}; the school keeps running.",
command.GetType().Name,
_id);
}
}
if (dirty)
{
PublishSnapshot();
BroadcastClock();
// Not written here: a client can send SetSpeed as fast as the socket allows, and each
// one used to be a synchronous file write on this thread. FlushSettings coalesces them.
_settingsDirty = true;
}
}
private static void CompleteOrphan(WorkerCommand command)
{
switch (command)
{
case WorkerCommand.Dump dump:
dump.Result.TrySetResult(null);
break;
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
break;
case WorkerCommand.GetPersonLog getLog:
getLog.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
break;
case WorkerCommand.HireStaff hire:
hire.Result.TrySetResult(Staffing.UnknownSchool());
break;
case WorkerCommand.AssignSubject assign:
assign.Result.TrySetResult(Staffing.UnknownSchool());
break;
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(Staffing.UnknownSchool());
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
case WorkerCommand.GetDressRules getRules:
getRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
break;
case WorkerCommand.SetDressRules setRules:
setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
break;
}
}
private static void FailCommand(WorkerCommand command, Exception exception)
{
switch (command)
{
case WorkerCommand.Dump dump:
dump.Result.TrySetException(exception);
break;
case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetException(exception);
break;
case WorkerCommand.GetPersonLog getLog:
getLog.Result.TrySetException(exception);
break;
case WorkerCommand.HireStaff hire:
hire.Result.TrySetException(exception);
break;
case WorkerCommand.AssignSubject assign:
assign.Result.TrySetException(exception);
break;
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetException(exception);
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetException(exception);
break;
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetException(exception);
break;
case WorkerCommand.GetDressRules getRules:
getRules.Result.TrySetException(exception);
break;
case WorkerCommand.SetDressRules setRules:
setRules.Result.TrySetException(exception);
break;
}
}
private StaffingOutcome ApplyHire(School school, string personId, string position) =>
ApplyStaffingChange(school, (catalog, roster, pool) =>
Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap));
private StaffingOutcome ApplyAssign(School school, string personId, string subject) =>
ApplyStaffingChange(school, (catalog, roster, pool) =>
Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
private StaffingOutcome ApplyUnassign(School school, string personId, string subject) =>
ApplyStaffingChange(school, (catalog, roster, pool) =>
Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
private StaffingOutcome ApplyStaffingChange(
School school,
Func<DefCatalog, Roster, ApplicantPool, StaffingOutcome> apply)
{
if (school.Roster is null || school.Applicants is null || school.Catalog is null)
{
return Staffing.UnknownSchool();
}
var outcome = apply(school.Catalog, school.Roster, school.Applicants);
if (outcome.Error == StaffingError.None)
{
school.ApplyStaffing(outcome.Roster, outcome.Pool);
PersistPeople();
RebuildTimetable(school);
}
return outcome;
}
private TimetableOutcome ApplyPin(
School school,
string classId,
string subject,
string roomId,
int day,
int period)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return TimetableOutcome.Fail(TimetableError.UnknownSchool);
}
if (school.Roster.Classes.All(item => item.Id != classId))
{
return TimetableOutcome.Fail(TimetableError.UnknownClass);
}
if (!school.Catalog.Subjects.TryGetValue(subject, out var subjectDef) || subjectDef.Abstract)
{
return TimetableOutcome.Fail(TimetableError.UnknownSubject);
}
if (school.Map.Rooms.All(room => room.Id != roomId))
{
return TimetableOutcome.Fail(TimetableError.UnknownRoom);
}
var teacherId = TeacherFor(school, classId, subject);
if (teacherId is null)
{
return TimetableOutcome.Fail(TimetableError.NoTeacher);
}
var pin = new LessonPlacement(classId, subject, teacherId, roomId, day, period, Locked: true);
var locks = (school.Timetable?.Lessons.Where(lesson => lesson.Locked) ?? [])
.Where(lesson => lesson.ClassId != classId || lesson.Subject != subject
|| lesson.Day != day || lesson.Period != period)
.Append(pin)
.ToArray();
var table = SchoolTimetables.Build(
school.Catalog,
school.Map,
school.Roster,
locks,
_options.SchoolWeekDays);
if (!table.Lessons.Any(lesson =>
lesson.Locked
&& lesson.ClassId == classId
&& lesson.Subject == subject
&& lesson.RoomId == roomId
&& lesson.Day == day
&& lesson.Period == period))
{
return TimetableOutcome.Fail(TimetableError.PinRejected);
}
ApplyTable(school, table, broadcast: true);
return TimetableOutcome.Ok(table);
}
private TimetableOutcome ApplyUnpin(School school, string classId, string subject, int day, int period)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return TimetableOutcome.Fail(TimetableError.UnknownSchool);
}
var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? [];
var match = locks.FirstOrDefault(lesson =>
lesson.ClassId == classId && lesson.Subject == subject && lesson.Day == day && lesson.Period == period);
if (match is null)
{
return TimetableOutcome.Fail(TimetableError.UnknownLesson);
}
var next = SchoolTimetables.Build(
school.Catalog,
school.Map,
school.Roster,
locks.Where(lesson => lesson != match).ToArray(),
_options.SchoolWeekDays);
ApplyTable(school, next, broadcast: true);
return TimetableOutcome.Ok(next);
}
}
@@ -0,0 +1,219 @@
using System.Diagnostics;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
using HSchool.Simulation;
namespace HSchool.Server.Game;
internal sealed partial class SchoolWorker
{
/// <summary>
/// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>.
/// A single click still lands within that window; a burst collapses into one write.
/// </summary>
private void FlushSettings()
{
if (!_settingsDirty || Stopwatch.GetElapsedTime(_lastSettingsSave) < _options.MinSaveInterval)
{
return;
}
Persist();
_settingsDirty = false;
_lastSettingsSave = Stopwatch.GetTimestamp();
}
private void PublishSnapshot()
{
var school = _school;
if (school is null)
{
return;
}
Volatile.Write(
ref _snapshot,
new SchoolState(
school.Id,
school.Name,
school.Clock.Time,
school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex,
school.Catalog?.PackIds ?? _modIds ?? [],
school.PeopleSeed,
_owner));
Volatile.Write(ref _rosterSnapshot, school.Roster);
Volatile.Write(ref _applicantSnapshot, school.Applicants);
Volatile.Write(ref _timetableSnapshot, school.Timetable);
Volatile.Write(ref _mapSnapshot, school.Map);
}
/// <summary>
/// Writes the composition file. Not called from the 30-second clock save — the roster and
/// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick.
/// </summary>
private void PersistPeople()
{
var school = _school;
if (school?.Roster is null)
{
return;
}
try
{
_store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster, school.Applicants));
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not save people for school {SchoolId}; composition stays in memory.", _id);
}
}
private void InstallTimetable(School school)
{
if (!_isNew)
{
var saved = _store.TryReadTimetable(_id);
if (saved is not null)
{
var restored = RestoreTimetable(school, saved);
school.SetTimetable(restored);
if (!saved.Lessons.SequenceEqual(restored.Lessons)
|| !saved.Uncovered.SequenceEqual(restored.Uncovered))
{
PersistTimetable(school);
}
return;
}
}
RebuildTimetable(school, broadcast: false);
}
private Timetable RestoreTimetable(School school, Timetable saved)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return saved;
}
var classIds = school.Roster.Classes.Select(item => item.Id).ToHashSet(StringComparer.Ordinal);
var peopleIds = school.Roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
var valid = saved.Lessons
.Where(lesson => classIds.Contains(lesson.ClassId) && peopleIds.Contains(lesson.TeacherId))
.ToArray();
if (valid.Length == saved.Lessons.Count)
{
return saved;
}
var locks = valid.Where(lesson => lesson.Locked).ToArray();
return SchoolTimetables.Build(
school.Catalog,
school.Map,
school.Roster,
locks,
_options.SchoolWeekDays);
}
private void RebuildTimetable(School school, bool broadcast = true)
{
if (school.Catalog is null || school.Map is null || school.Roster is null)
{
return;
}
var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? [];
ApplyTable(
school,
SchoolTimetables.Build(school.Catalog, school.Map, school.Roster, locks, _options.SchoolWeekDays),
broadcast);
}
private static string? TeacherFor(School school, string classId, string subject)
{
var existing = school.Timetable?.Lessons.FirstOrDefault(lesson =>
lesson.ClassId == classId && lesson.Subject == subject);
if (existing is not null)
{
return existing.TeacherId;
}
return school.Roster?.People
.Where(person => person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal))
.OrderBy(person => person.Id, StringComparer.Ordinal)
.Select(person => person.Id)
.FirstOrDefault();
}
private void ApplyTable(School school, Timetable table, bool broadcast)
{
school.SetTimetable(table);
PersistTimetable(school);
PublishSnapshot();
if (broadcast)
{
BroadcastPresence();
}
}
/// <summary>
/// Writes the lesson table. Not called from the 30-second clock save — the table changes on
/// hire, unassign, pin and yearly intake, not every tick.
/// </summary>
private void PersistTimetable(School school)
{
if (school.Timetable is null)
{
return;
}
try
{
_store.SaveTimetable(school.Id, school.Timetable);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not save the timetable for school {SchoolId}; it stays in memory.", _id);
}
}
private void Persist()
{
var school = _school;
if (school is null)
{
return;
}
// A full disk or a locked file must not end the school; the next save will try again.
try
{
_store.Save(new SchoolSave
{
Format = SchoolStore.CurrentFormat,
Id = school.Id,
Name = school.Name,
GameTime = school.Clock.Time,
Running = school.Clock.IsRunning,
SpeedIndex = school.Clock.SpeedIndex,
ModIds = school.Catalog?.PackIds,
Map = school.Map,
CountryId = school.CountryId,
ClimatePresetId = school.ClimatePresetId,
NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(),
DressRules = school.DressRules,
Owner = _owner,
PortraitSettings = _portraitSettings,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not save school {SchoolId}; it keeps running unsaved.", _id);
}
}
}
@@ -0,0 +1,491 @@
using System.Diagnostics;
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
namespace HSchool.Server.Game;
internal sealed partial class SchoolWorker
{
private void RunSync()
{
try
{
RunLoop(_stopping.Token);
}
catch (SchoolContentUnavailableException ex)
{
_logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id);
_started.TrySetException(ex);
ReportFailure();
}
catch (Exception ex)
{
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
_started.TrySetException(ex);
ReportFailure();
}
}
/// <summary>
/// Tells the supervisor this school is gone. Without it a dead worker stayed in the table and
/// the menu kept drawing its card with a frozen clock, as if the school were alive.
/// </summary>
private void ReportFailure()
{
if (_stopping.IsCancellationRequested)
{
// Already being torn down on purpose; the supervisor knows.
return;
}
try
{
_onFailed(_id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not report the failure of school {SchoolId}.", _id);
}
}
private void RunLoop(CancellationToken cancellationToken)
{
var packIds = _mods.NormalizePackIds(_modIds);
_logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds));
foreach (var packId in packIds)
{
if (!_mods.PackExists(packId))
{
throw new SchoolContentUnavailableException(
$"School {_id} needs mod '{packId}', but that folder is missing.");
}
}
var catalog = _mods.LoadCatalog(packIds, _logger);
Volatile.Write(ref _catalogSnapshot, catalog);
var map = _mods.LoadMap(packIds, _savedMap);
Volatile.Write(ref _mapSnapshot, map);
try
{
MapValidator.Validate(map, catalog);
}
catch (MapValidationException ex)
{
throw new SchoolContentUnavailableException(ex.Message, ex);
}
var school = _isNew
? School.Create(_id, _name, _time, catalog, map)
: School.Load(_id, _name, _time, _running, _speedIndex, catalog, map);
var peopleDirty = false;
try
{
peopleDirty = InstallPeople(school, catalog, map);
}
catch
{
school.Dispose();
throw;
}
_school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
PublishSnapshot();
if (_isNew)
{
Persist();
}
if (peopleDirty)
{
PersistPeople();
}
_started.TrySetResult();
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
var lastSave = lastTimestamp;
var peopleChanged = false;
try
{
while (!cancellationToken.IsCancellationRequested)
{
if (!WaitForTick(timer, cancellationToken))
{
break;
}
DrainMailbox();
var now = Stopwatch.GetTimestamp();
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
var steps = 0;
peopleChanged = false;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond);
_metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
steps++;
}
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
{
_logger.LogWarning(
"School {SchoolId} is behind by {Backlog:F0} ms; dropping the backlog.",
_id,
accumulator * 1000);
accumulator = 0d;
}
if (peopleChanged)
{
PersistPeople();
if (school.TimetableDirty)
{
RebuildTimetable(school);
}
}
if (steps > 0)
{
PublishSnapshot();
BroadcastClock();
MaybeBroadcastPresence(school);
}
FlushSettings();
if (Stopwatch.GetElapsedTime(lastSave) >= _options.SaveInterval)
{
Persist();
lastSave = Stopwatch.GetTimestamp();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
DrainMailbox();
if (Volatile.Read(ref _persistOnStop))
{
Persist();
}
school.Dispose();
_school = null;
}
}
/// <summary>
/// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine;
/// <see cref="School.Tick"/> then runs here, not as a pool callback.
/// </summary>
private static bool WaitForTick(PeriodicTimer timer, CancellationToken cancellationToken)
{
try
{
return timer.WaitForNextTickAsync(cancellationToken).AsTask().GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
return false;
}
}
private void MaybeBroadcastPresence(School school)
{
_presenceAge++;
var interval = Math.Max(1, _options.TickRate / 2);
if (_presenceAge < interval)
{
return;
}
_presenceAge = 0;
BroadcastPresence();
}
private void ApplySkip(School school)
{
var result = school.TrySkipEmpty();
if (!result.Succeeded)
{
return;
}
if (result.PeopleChanged)
{
PersistPeople();
if (school.TimetableDirty)
{
RebuildTimetable(school);
}
}
PublishSnapshot();
Persist();
BroadcastClock();
BroadcastPresence();
_presenceAge = 0;
}
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
{
var countryId = ResolveCountryId(catalog, _countryId);
if (countryId is null)
{
throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog.");
}
if (!catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract)
{
throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog.");
}
_climatePresetId = ResolveClimatePreset(country, _climatePresetId);
var demand = SchoolDemand.From(catalog, map);
Roster roster;
ApplicantPool applicants;
int seed;
var generated = false;
string? native;
if (_isNew)
{
if (_createSeed is not int createSeed)
{
throw new InvalidOperationException($"School {_id} was created without a people seed.");
}
seed = createSeed;
native = ResolveNative(country, seed, _nativeLanguage, generating: true);
_nativeLanguage = native;
roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native);
applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native);
generated = true;
}
else
{
var loaded = _store.TryReadPeople(_id);
if (loaded is null)
{
throw new SchoolContentUnavailableException(
$"School {_id} has no people file; the school was left unstarted.");
}
seed = loaded.Seed;
native = ResolveNative(country, seed, _nativeLanguage, generating: false);
_nativeLanguage = native;
roster = loaded.ToRoster();
if (loaded.Applicants is { Applicants.Count: > 0 })
{
applicants = loaded.Applicants;
}
else
{
applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native);
generated = true;
}
if (DressGenerator.NeedsDressing(roster, applicants))
{
roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time);
applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time);
generated = true;
}
RequireKnownApparel(catalog, roster, applicants);
}
if (OpinionGenerator.NeedsFamilyOpinions(roster))
{
roster = OpinionGenerator.SeedFamily(catalog, roster);
generated = true;
}
var assigned = OrientationGenerator.Assign(catalog, roster, seed);
generated |= !ReferenceEquals(assigned, roster);
roster = assigned;
var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed);
generated |= !ReferenceEquals(assignedPool, applicants);
applicants = assignedPool;
if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0)
{
generated = true;
}
roster = LockerAssigner.Apply(catalog, map, roster);
if (!RosterFit.Matches(roster, demand))
{
throw new SchoolContentUnavailableException(
$"School {_id} roster does not match its map; the people file was left untouched.");
}
school.InstallPeople(roster, seed, countryId, applicants, _nativeLanguage, _climatePresetId);
InstallTimetable(school);
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
school.RestorePresence(_savedPresence);
return generated;
}
private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants)
{
foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person)))
{
foreach (var item in person.Items)
{
if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract)
{
throw new SchoolContentUnavailableException(
$"School roster references unusable thing '{item.Def}'.");
}
}
}
}
private static string? ResolveCountryId(DefCatalog catalog, string? requested)
{
if (string.IsNullOrWhiteSpace(requested))
{
return null;
}
return catalog.Countries.TryGetValue(requested, out var country) && !country.Abstract
? requested
: null;
}
private static string? ResolveClimatePreset(CountryDef country, string? requested)
{
if (!string.IsNullOrWhiteSpace(requested) && country.ClimatePresets.Contains(requested, StringComparer.Ordinal))
{
return requested;
}
return CountryClimate.Pick(country, schoolSeed: 0, rollIfOmitted: false);
}
private static string? ResolveNative(
CountryDef country,
int schoolSeed,
string? requested,
bool generating) =>
NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested));
private void BroadcastClock()
{
var school = _school;
if (school is null)
{
return;
}
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
{
BroadcastClockTo(client, school);
}
}
}
private void SendMapSnapshot(GameClient client, School school)
{
if (school.Catalog is null || school.Map is null)
{
return;
}
var locale = ProtocolConstants.CatalogLocale(client.Locale);
var view = MapView.Build(school.Catalog, school.Map, locale);
var nodes = new MapSnapshotNode[view.Count];
for (var i = 0; i < view.Count; i++)
{
var node = view[i];
var items = new MapSnapshotItem[node.Items.Count];
for (var item = 0; item < node.Items.Count; item++)
{
items[item] = new MapSnapshotItem(node.Items[item].Name, (byte)node.Items[item].Count);
}
nodes[i] = new MapSnapshotNode(
(byte)node.Kind,
node.Id,
node.ParentId,
node.Name,
(ushort)node.PupilSlots,
items,
node.Positions);
}
// Sized from the message, not from the inbound frame limit: a map the player enlarged in
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
var message = new ServerMapSnapshotMessage(school.Id, nodes);
var frame = new byte[ProtocolCodec.MapSnapshotSize(message)];
var length = ProtocolCodec.WriteMapSnapshot(frame, message);
client.TrySendReliable(frame.AsMemory(0, length));
}
private void BroadcastClockTo(GameClient client, School school)
{
var skip = school.PeekSkipEmpty();
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
school.Id,
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex,
skip.Allowed,
skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0,
school.Weather.Tenths,
(byte)school.Weather.Precipitation));
client.TrySend(frame.AsMemory(0, length));
}
private void BroadcastPresence()
{
var school = _school;
if (school is null)
{
return;
}
foreach (var client in _clients.All)
{
if (client.IsReady && client.OpenSchoolId == _id)
{
SendPresence(client, school);
}
}
}
private void SendPresence(GameClient client, School school)
{
var locale = ProtocolConstants.CatalogLocale(client.Locale);
var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale);
var frame = new byte[ProtocolCodec.PresenceSize(message)];
var length = ProtocolCodec.WritePresence(frame, message);
client.TrySendReliable(frame.AsMemory(0, length));
}
}
File diff suppressed because it is too large Load Diff