Enhance school creation functionality by introducing support for name sets in the API and UI. Update the catalog to include skills, traits, body attributes, needs, and name sets, improving character generation capabilities. Revise localization strings for better user guidance and update tests to validate the new name set functionality and ensure robustness in school creation processes.
ci / server (push) Failing after 3m45s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 18:57:01 +03:00
parent 38afbcad36
commit e6182e0e45
56 changed files with 3706 additions and 9 deletions
+4
View File
@@ -41,9 +41,11 @@ const ru = {
errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.',
errorUnknownMod: 'Выбранный мод не найден.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды',
nameSetTitle: 'Набор имён',
coreModLocked: '{id} (всегда включён)',
mapEditorTitle: 'Карта',
editMap: 'Редактировать карту',
@@ -126,9 +128,11 @@ const en: Messages = {
errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.',
errorUnknownMod: 'A selected mod is missing.',
errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.',
catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods',
nameSetTitle: 'Name set',
coreModLocked: '{id} (always on)',
mapEditorTitle: 'Map',
editMap: 'Edit map',
+3
View File
@@ -44,6 +44,7 @@ export async function fetchRandomName(lang: string): Promise<string> {
export interface CreateSchoolOptions {
readonly modIds?: readonly string[];
readonly map?: MapLayout;
readonly nameSetId?: string;
}
export async function createSchool(
@@ -59,6 +60,7 @@ export async function createSchool(
startDate: startDate.toISOString(),
modIds: extras.modIds ?? [],
map: extras.map ?? null,
nameSetId: extras.nameSetId ?? null,
}),
});
}
@@ -109,6 +111,7 @@ export interface CatalogResponse {
readonly rooms: readonly RoomInfo[];
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
readonly nameSets: readonly DefInfo[];
}
export async function fetchMods(): Promise<readonly ModInfo[]> {
@@ -32,12 +32,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null;
let nameSetId: string | null = null;
const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' });
const modsList = el('div', { class: 'mod-list' });
modsField.append(modsLabel, modsList);
const nameSetLabel = el('span', { class: 'field__label' });
const nameSetSelect = el('select', { class: 'input' });
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
nameSetSelect.addEventListener('change', () => {
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value;
});
const mapLabel = el('span', { class: 'field__label' });
const editMapButton = el('button', { class: 'button', type: 'button' });
const mapHint = el('p', { class: 'hint' });
@@ -78,6 +86,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField,
nameSetField,
mapField,
error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
@@ -112,10 +121,25 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const applyCatalog = (next: CatalogResponse): void => {
catalog = next;
currentMap = structuredClone(next.defaultMap);
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
paintNameSets(next);
paintMapHint();
setBusy(busy);
};
const paintNameSets = (next: CatalogResponse): void => {
nameSetSelect.replaceChildren();
for (const set of next.nameSets) {
const option = el('option', { text: set.label });
option.value = set.defName;
nameSetSelect.append(option);
}
nameSetSelect.value = nameSetId ?? '';
nameSetSelect.disabled = next.nameSets.length <= 1;
};
const reloadCatalog = async (): Promise<void> => {
try {
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
@@ -168,6 +192,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const localize = (): void => {
title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle');
nameSetLabel.textContent = t('nameSetTitle');
nameLabel.textContent = t('schoolName');
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle');
@@ -178,6 +203,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
submitButton.textContent = t('create');
cancelButton.textContent = t('cancel');
paintMapHint();
if (catalog !== null) {
paintNameSets(catalog);
}
};
localize();
@@ -227,6 +255,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
.create(nameInput.value.trim(), startDate, {
modIds: [...extraModIds],
map: currentMap,
nameSetId: nameSetId ?? undefined,
})
.then((school) => modal.close(school))
.catch((reason: unknown) => {
@@ -263,6 +292,8 @@ function describe(reason: unknown): string {
return t('errorUnknownMod');
case 'invalid-catalog':
return t('errorInvalidCatalog');
case 'unknown-name-set':
return t('errorUnknownNameSet');
default:
return reason.message;
}
+27
View File
@@ -302,6 +302,11 @@ public sealed class CatalogLoader
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
var skills = new Dictionary<string, SkillDef>(StringComparer.Ordinal);
var traits = new Dictionary<string, TraitDef>(StringComparer.Ordinal);
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -331,6 +336,21 @@ public sealed class CatalogLoader
case DefKind.Territory:
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(json);
break;
case DefKind.Skill:
skills[key.Name] = Jsonc.Deserialize<SkillDef>(json);
break;
case DefKind.Trait:
traits[key.Name] = Jsonc.Deserialize<TraitDef>(json);
break;
case DefKind.BodyAttribute:
bodyAttributes[key.Name] = Jsonc.Deserialize<BodyAttributeDef>(json);
break;
case DefKind.Need:
needs[key.Name] = Jsonc.Deserialize<NeedDef>(json);
break;
case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
break;
}
}
@@ -344,6 +364,11 @@ public sealed class CatalogLoader
buildings,
floors,
territories,
skills,
traits,
bodyAttributes,
needs,
nameSets,
ru,
en);
}
@@ -397,6 +422,8 @@ public sealed class CatalogLoader
}
}
}
PeopleDefValidator.Validate(catalog);
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
+54
View File
@@ -16,6 +16,11 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BuildingDef> buildings,
IReadOnlyDictionary<string, FloorDef> floors,
IReadOnlyDictionary<string, TerritoryDef> territories,
IReadOnlyDictionary<string, SkillDef> skills,
IReadOnlyDictionary<string, TraitDef> traits,
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -28,6 +33,11 @@ public sealed class DefCatalog
Buildings = buildings;
Floors = floors;
Territories = territories;
Skills = skills;
Traits = traits;
BodyAttributes = bodyAttributes;
Needs = needs;
NameSets = nameSets;
_ru = ru;
_en = en;
}
@@ -50,6 +60,16 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, TerritoryDef> Territories { get; }
public IReadOnlyDictionary<string, SkillDef> Skills { get; }
public IReadOnlyDictionary<string, TraitDef> Traits { get; }
public IReadOnlyDictionary<string, BodyAttributeDef> BodyAttributes { get; }
public IReadOnlyDictionary<string, NeedDef> Needs { get; }
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; }
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -65,6 +85,11 @@ public sealed class DefCatalog
DefKind.Building => Buildings.GetValueOrDefault(defName),
DefKind.Floor => Floors.GetValueOrDefault(defName),
DefKind.Territory => Territories.GetValueOrDefault(defName),
DefKind.Skill => Skills.GetValueOrDefault(defName),
DefKind.Trait => Traits.GetValueOrDefault(defName),
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.GetValueOrDefault(defName),
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
_ => null,
};
@@ -115,6 +140,35 @@ public sealed class DefCatalog
BuildingDef => DefKind.Building,
FloorDef => DefKind.Floor,
TerritoryDef => DefKind.Territory,
SkillDef => DefKind.Skill,
TraitDef => DefKind.Trait,
BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
/// <summary>Incompatibilities written on either trait — a one-way list is mutual.</summary>
public IReadOnlySet<string> TraitIncompatibilities(string defName)
{
var set = new HashSet<string>(StringComparer.Ordinal);
if (Traits.TryGetValue(defName, out var trait))
{
foreach (var other in trait.Incompatible)
{
set.Add(other);
}
}
foreach (var candidate in Traits.Values)
{
if (candidate.Incompatible.Contains(defName, StringComparer.Ordinal))
{
set.Add(candidate.DefName);
}
}
set.Remove(defName);
return set;
}
}
+5
View File
@@ -10,6 +10,11 @@ public enum DefKind
Building,
Floor,
Territory,
Skill,
Trait,
BodyAttribute,
Need,
NameSet,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+2
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace HSchool.Content;
@@ -12,6 +13,7 @@ public static class Jsonc
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static JsonDocumentOptions DocumentOptions { get; } = new()
+302
View File
@@ -0,0 +1,302 @@
namespace HSchool.Content;
public enum GrammaticalCase
{
Nominative,
Genitive,
Dative,
Accusative,
Instrumental,
Prepositional,
}
/// <summary>
/// How a written name becomes six cases. Models live here; a <see cref="CaseTable"/> on the
/// entry wins when the rule is wrong. People generation calls this; Content tests pin it.
/// </summary>
public static class NameGrammar
{
public const string SlavicPatronymic = "slavic";
public const string Hard = "hard";
public const string Soft = "soft";
public const string A = "a";
public const string Ya = "ya";
public const string Iya = "iya";
public const string Ov = "ov";
public const string In = "in";
public const string Sky = "sky";
public const string Indeclinable = "indeclinable";
public static readonly IReadOnlyList<string> GivenModels = [Hard, Soft, A, Ya, Iya, Indeclinable];
public static readonly IReadOnlyList<string> SurnameModels = [Ov, In, Sky, Indeclinable];
public static readonly IReadOnlyList<string> PatronymicRules = [SlavicPatronymic];
public static bool IsKnownGiven(string model) =>
GivenModels.Any(candidate => candidate.Equals(model, StringComparison.OrdinalIgnoreCase));
public static bool IsKnownSurname(string model) =>
SurnameModels.Any(candidate => candidate.Equals(model, StringComparison.OrdinalIgnoreCase));
public static bool IsKnownPatronymic(string rule) =>
PatronymicRules.Any(candidate => candidate.Equals(rule, StringComparison.OrdinalIgnoreCase));
public static string InflectGiven(GivenNameEntry entry, GrammaticalCase grammaticalCase, string defaultModel)
{
if (entry.Cases is { } table)
{
return table[grammaticalCase];
}
var model = string.IsNullOrWhiteSpace(entry.Declension) ? defaultModel : entry.Declension;
return InflectGivenForm(entry.Form, model, grammaticalCase);
}
public static string InflectSurname(
SurnameEntry entry,
bool female,
GrammaticalCase grammaticalCase,
string defaultModel)
{
var table = female ? entry.FemaleCases : entry.MaleCases;
if (table is not null)
{
return table[grammaticalCase];
}
var nominative = female ? entry.Female : entry.Male;
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
var model = string.IsNullOrWhiteSpace(entry.Declension) ? defaultModel : entry.Declension;
return InflectSurnameForm(nominative, model, female, grammaticalCase);
}
public static string InflectPatronymic(string nominative, bool female, GrammaticalCase grammaticalCase)
{
if (string.IsNullOrWhiteSpace(nominative) || grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return female
? InflectGivenForm(nominative, A, grammaticalCase)
: InflectGivenForm(nominative, Hard, grammaticalCase);
}
public static string Patronymic(string fatherNominative, bool female, string rule)
{
if (!rule.Equals(SlavicPatronymic, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentOutOfRangeException(nameof(rule), rule, "Unknown patronymic rule.");
}
return SlavicFromFather(fatherNominative, female);
}
public static string InflectGivenForm(string nominative, string model, GrammaticalCase grammaticalCase)
{
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return model.ToLowerInvariant() switch
{
Hard => HardGiven(nominative, grammaticalCase),
Soft => SoftGiven(nominative, grammaticalCase),
A => AGiven(nominative, grammaticalCase),
Ya => YaGiven(nominative, grammaticalCase),
Iya => IyaGiven(nominative, grammaticalCase),
Indeclinable => nominative,
_ => throw new ArgumentOutOfRangeException(nameof(model), model, "Unknown given-name declension."),
};
}
public static string InflectSurnameForm(string nominative, string model, bool female, GrammaticalCase grammaticalCase)
{
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return model.ToLowerInvariant() switch
{
Ov => OvSurname(nominative, female, grammaticalCase),
In => InSurname(nominative, female, grammaticalCase),
Sky => SkySurname(nominative, female, grammaticalCase),
Indeclinable => nominative,
_ => throw new ArgumentOutOfRangeException(nameof(model), model, "Unknown surname declension."),
};
}
private static string HardGiven(string nom, GrammaticalCase grammaticalCase)
{
// Иван → Ивана, Ивану, Ивана, Иваном, Иване. Пётр → Петра.
var stem = nom.EndsWith('ь') ? nom[..^1] : nom;
stem = stem.Replace('ё', 'е').Replace('Ё', 'Е');
if (stem.EndsWith('й'))
{
stem = stem[..^1];
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "я",
GrammaticalCase.Dative => stem + "ю",
GrammaticalCase.Accusative => stem + "я",
GrammaticalCase.Instrumental => stem + "ем",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "а",
GrammaticalCase.Dative => stem + "у",
GrammaticalCase.Accusative => stem + "а",
GrammaticalCase.Instrumental => stem + "ом",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string SoftGiven(string nom, GrammaticalCase grammaticalCase)
{
// Игорь → Игоря; Любовь needs a table.
var stem = nom.EndsWith('ь') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "я",
GrammaticalCase.Dative => stem + "ю",
GrammaticalCase.Accusative => stem + "я",
GrammaticalCase.Instrumental => stem + "ем",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string AGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith('а') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ы",
GrammaticalCase.Dative => stem + "е",
GrammaticalCase.Accusative => stem + "у",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string YaGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith('я') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "и",
GrammaticalCase.Dative => stem + "е",
GrammaticalCase.Accusative => stem + "ю",
GrammaticalCase.Instrumental => stem + "ей",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string IyaGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith("ия", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ии",
GrammaticalCase.Dative => stem + "ии",
GrammaticalCase.Accusative => stem + "ию",
GrammaticalCase.Instrumental => stem + "ией",
GrammaticalCase.Prepositional => stem + "ии",
_ => nom,
};
}
private static string OvSurname(string nom, bool female, GrammaticalCase grammaticalCase)
{
if (female)
{
var stem = nom.EndsWith('а') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ой",
GrammaticalCase.Dative => stem + "ой",
GrammaticalCase.Accusative => stem + "у",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "ой",
_ => nom,
};
}
return grammaticalCase switch
{
GrammaticalCase.Genitive => nom + "а",
GrammaticalCase.Dative => nom + "у",
GrammaticalCase.Accusative => nom + "а",
GrammaticalCase.Instrumental => nom + "ым",
GrammaticalCase.Prepositional => nom + "е",
_ => nom,
};
}
private static string InSurname(string nom, bool female, GrammaticalCase grammaticalCase) =>
OvSurname(nom, female, grammaticalCase);
private static string SkySurname(string nom, bool female, GrammaticalCase grammaticalCase)
{
if (female)
{
var stem = nom.EndsWith("ая", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ой",
GrammaticalCase.Dative => stem + "ой",
GrammaticalCase.Accusative => stem + "ую",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "ой",
_ => nom,
};
}
var maleStem = nom.EndsWith("ий", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => maleStem + "ого",
GrammaticalCase.Dative => maleStem + "ому",
GrammaticalCase.Accusative => maleStem + "ого",
GrammaticalCase.Instrumental => maleStem + "им",
GrammaticalCase.Prepositional => maleStem + "ом",
_ => nom,
};
}
private static string SlavicFromFather(string father, bool female)
{
var name = father.Replace('ё', 'е').Replace('Ё', 'Е');
if (name.Equals("Илья", StringComparison.Ordinal))
{
return female ? "Ильинична" : "Ильич";
}
if (name.EndsWith('й') || name.EndsWith('ь'))
{
var stem = name[..^1];
return female ? stem + "евна" : stem + "евич";
}
if (name.EndsWith('а') || name.EndsWith('я'))
{
var stem = name[..^1];
return female ? stem + "ична" : stem + "ич";
}
return female ? name + "овна" : name + "ович";
}
}
+15
View File
@@ -86,6 +86,21 @@ internal static class PackPaths
case "territories":
kind = DefKind.Territory;
return true;
case "skills":
kind = DefKind.Skill;
return true;
case "traits":
kind = DefKind.Trait;
return true;
case "bodies":
kind = DefKind.BodyAttribute;
return true;
case "needs":
kind = DefKind.Need;
return true;
case "namesets":
kind = DefKind.NameSet;
return true;
default:
kind = default;
return false;
+282
View File
@@ -0,0 +1,282 @@
namespace HSchool.Content;
internal static class PeopleDefValidator
{
public static void Validate(DefCatalog catalog)
{
foreach (var body in catalog.BodyAttributes.Values)
{
ValidateBody(body);
}
foreach (var skill in catalog.Skills.Values)
{
ValidateSkill(skill, catalog);
}
foreach (var trait in catalog.Traits.Values)
{
ValidateTrait(trait, catalog);
}
foreach (var need in catalog.Needs.Values)
{
ValidateNeed(need);
}
foreach (var names in catalog.NameSets.Values)
{
ValidateNameSet(names);
}
}
private static void ValidateBody(BodyAttributeDef def)
{
if (def.DefName.Equals(BodyBuilds.Attribute, StringComparison.Ordinal))
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' is reserved for the derived build.");
}
switch (def.Kind)
{
case BodyAttributeKind.Number:
if (def.Distributions.Count == 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' of kind number needs at least one distribution.");
}
foreach (var row in def.Distributions)
{
RequireSex(row.Sex, def.DefName);
RequireAgeWindow(row.AgeMin, row.AgeMax, def.DefName);
if (row.Distribution.StdDev < 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has a negative stdDev.");
}
RequireRange(row.Range, $"BodyAttributeDef '{def.DefName}'");
}
break;
case BodyAttributeKind.Choice:
if (def.Options.Count == 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' of kind choice needs at least one option.");
}
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var option in def.Options)
{
if (string.IsNullOrWhiteSpace(option.Value) || !values.Add(option.Value))
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has a missing or duplicate option.");
}
if (option.Weight < 1)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' option '{option.Value}' needs a positive weight.");
}
RequireSex(option.Sex, def.DefName);
RequireAgeWindow(option.AgeMin, option.AgeMax, def.DefName);
}
break;
default:
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has unknown kind '{def.Kind}'.");
}
}
private static void ValidateSkill(SkillDef skill, DefCatalog catalog)
{
RequireRange(skill.Range, $"SkillDef '{skill.DefName}'");
if (skill.Distribution is { StdDev: < 0 })
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' has a negative stdDev.");
}
foreach (var limit in skill.BodyLimits)
{
if (string.IsNullOrWhiteSpace(limit.Attribute))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits entry is missing attribute.");
}
if (limit.Min is { } min && limit.Max is { } max && min > max)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits min is above max.");
}
if (limit.Attribute.Equals(BodyBuilds.Attribute, StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(limit.Value) || !BodyBuilds.IsKnown(limit.Value))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown build '{limit.Value}'.");
}
continue;
}
if (!catalog.BodyAttributes.TryGetValue(limit.Attribute, out var body))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown attribute '{limit.Attribute}'.");
}
if (body.Kind != BodyAttributeKind.Choice)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits attribute '{limit.Attribute}' is not categorical.");
}
if (string.IsNullOrWhiteSpace(limit.Value)
|| !body.Options.Any(option => option.Value.Equals(limit.Value, StringComparison.Ordinal)))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown value '{limit.Value}' of '{limit.Attribute}'.");
}
}
}
private static void ValidateTrait(TraitDef trait, DefCatalog catalog)
{
if (trait.Weight < 1)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' weight must be at least 1.");
}
RequireRange(trait.Age, $"TraitDef '{trait.DefName}'");
foreach (var role in trait.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' has unknown role '{role}'.");
}
}
foreach (var other in trait.Incompatible)
{
if (!catalog.Traits.ContainsKey(other))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' is incompatible with unknown TraitDef '{other}'.");
}
}
foreach (var modifier in trait.SkillModifiers)
{
if (!catalog.Skills.ContainsKey(modifier.Skill))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' skill modifier references unknown SkillDef '{modifier.Skill}'.");
}
}
}
private static void ValidateNeed(NeedDef need)
{
if (need.Min > need.Max)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' min is above max.");
}
if (need.Initial < need.Min || need.Initial > need.Max)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' initial is outside minmax.");
}
if (need.DecayPerHour < 0)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' decayPerHour cannot be negative.");
}
}
private static void ValidateNameSet(NameSetDef names)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'.");
}
if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension.");
}
if (!NameGrammar.IsKnownSurname(names.DefaultSurnameDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultSurnameDeclension.");
}
if (names.MaleGiven.Count == 0 || names.FemaleGiven.Count == 0 || names.Surnames.Count == 0)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' needs male names, female names and surnames.");
}
foreach (var given in names.MaleGiven.Concat(names.FemaleGiven))
{
if (string.IsNullOrWhiteSpace(given.Form))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has an empty given name.");
}
if (given.Cases is null)
{
var model = string.IsNullOrWhiteSpace(given.Declension)
? names.DefaultGivenDeclension
: given.Declension;
if (!NameGrammar.IsKnownGiven(model))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' given '{given.Form}' has unknown declension '{model}'.");
}
}
}
foreach (var surname in names.Surnames)
{
if (string.IsNullOrWhiteSpace(surname.Male) || string.IsNullOrWhiteSpace(surname.Female))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has a surname missing a gendered form.");
}
if (surname.MaleCases is null && surname.FemaleCases is null)
{
var model = string.IsNullOrWhiteSpace(surname.Declension)
? names.DefaultSurnameDeclension
: surname.Declension;
if (!NameGrammar.IsKnownSurname(model))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' surname '{surname.Male}' has unknown declension '{model}'.");
}
}
}
}
private static void RequireSex(string? sex, string defName)
{
if (sex is null)
{
return;
}
if (!sex.Equals("male", StringComparison.OrdinalIgnoreCase)
&& !sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
throw new ContentLoadException($"Def '{defName}' has unknown sex '{sex}'.");
}
}
private static void RequireAgeWindow(int? min, int? max, string defName)
{
if (min is { } lo && max is { } hi && lo > hi)
{
throw new ContentLoadException($"Def '{defName}' has ageMin above ageMax.");
}
}
private static void RequireRange(IntRange? range, string where)
{
if (range is { } value && value.Min > value.Max)
{
throw new ContentLoadException($"{where} range min is above max.");
}
}
}
+251
View File
@@ -0,0 +1,251 @@
namespace HSchool.Content;
/// <summary>Roles the generator and traits talk about. Code, not a def — new roles are a rebuild.</summary>
public static class PersonRoles
{
public const string Student = "student";
public const string Staff = "staff";
public const string Parent = "parent";
public static bool IsKnown(string role) =>
role.Equals(Student, StringComparison.OrdinalIgnoreCase)
|| role.Equals(Staff, StringComparison.OrdinalIgnoreCase)
|| role.Equals(Parent, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Derived body type, not a <see cref="BodyAttributeDef"/>. Skill limits may name it the same
/// way they name hair colour; the generator computes it from height and weight.
/// </summary>
public static class BodyBuilds
{
public const string Attribute = "Build";
public const string Skinny = "Skinny";
public const string Average = "Average";
public const string Athletic = "Athletic";
public const string Heavy = "Heavy";
public const string Obese = "Obese";
public static readonly IReadOnlyList<string> Values =
[Skinny, Average, Athletic, Heavy, Obese];
public static bool IsKnown(string value) =>
Values.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
/// <summary>
/// WHO-ish bands with a fit slice in the healthy range so <c>Athletic</c> is derived, not rolled.
/// Height in centimetres, weight in kilograms.
/// </summary>
public static string FromHeightAndWeight(int heightCm, int weightKg)
{
if (heightCm <= 0)
{
return Average;
}
var metres = heightCm / 100d;
var bmi = weightKg / (metres * metres);
if (bmi < 18.5)
{
return Skinny;
}
if (bmi < 22.0)
{
return Average;
}
if (bmi < 25.0)
{
return Athletic;
}
if (bmi < 30.0)
{
return Heavy;
}
return Obese;
}
}
public sealed class IntRange
{
public int Min { get; init; }
public int Max { get; init; } = 100;
}
public sealed class StatDistribution
{
public float Mean { get; init; }
public float StdDev { get; init; } = 1;
}
public sealed class AgeMeanPoint
{
public int Age { get; init; }
public float Mean { get; init; }
}
public sealed class BodySkillLimit
{
public required string Attribute { get; init; }
public string? Value { get; init; }
public int? Min { get; init; }
public int? Max { get; init; }
}
public sealed class SkillDef : Def
{
public IntRange Range { get; init; } = new();
public StatDistribution? Distribution { get; init; }
public IReadOnlyList<AgeMeanPoint> AgeMeans { get; init; } = [];
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
}
public sealed class TraitSkillModifier
{
public required string Skill { get; init; }
public int Offset { get; init; }
}
public sealed class TraitDef : Def
{
public int Weight { get; init; } = 1;
public IReadOnlyList<string> Incompatible { get; init; } = [];
/// <summary>Empty means every role. Values are <see cref="PersonRoles"/> ids.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
public IntRange? Age { get; init; }
public IReadOnlyList<TraitSkillModifier> SkillModifiers { get; init; } = [];
}
public enum BodyAttributeKind
{
Number,
Choice,
}
public sealed class SexAgeDistribution
{
/// <summary><c>male</c>, <c>female</c>, or omit for both.</summary>
public string? Sex { get; init; }
public int? AgeMin { get; init; }
public int? AgeMax { get; init; }
public required StatDistribution Distribution { get; init; }
public IntRange? Range { get; init; }
}
public sealed class WeightedOption
{
public required string Value { get; init; }
public int Weight { get; init; } = 1;
public string? Sex { get; init; }
public int? AgeMin { get; init; }
public int? AgeMax { get; init; }
}
public sealed class BodyAttributeDef : Def
{
public BodyAttributeKind Kind { get; init; }
public IReadOnlyList<SexAgeDistribution> Distributions { get; init; } = [];
public IReadOnlyList<WeightedOption> Options { get; init; } = [];
}
public sealed class NeedDef : Def
{
public float Initial { get; init; } = 1;
public float DecayPerHour { get; init; }
public float Min { get; init; }
public float Max { get; init; } = 1;
}
public sealed class CaseTable
{
public required string Nom { get; init; }
public required string Gen { get; init; }
public required string Dat { get; init; }
public required string Acc { get; init; }
public required string Ins { get; init; }
public required string Pre { get; init; }
public string this[GrammaticalCase grammaticalCase] => grammaticalCase switch
{
GrammaticalCase.Nominative => Nom,
GrammaticalCase.Genitive => Gen,
GrammaticalCase.Dative => Dat,
GrammaticalCase.Accusative => Acc,
GrammaticalCase.Instrumental => Ins,
GrammaticalCase.Prepositional => Pre,
_ => Nom,
};
}
public sealed class GivenNameEntry
{
public required string Form { get; init; }
public string? Declension { get; init; }
public CaseTable? Cases { get; init; }
}
public sealed class SurnameEntry
{
public required string Male { get; init; }
public required string Female { get; init; }
public string? Declension { get; init; }
public CaseTable? MaleCases { get; init; }
public CaseTable? FemaleCases { get; init; }
}
public sealed class NameSetDef : Def
{
public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic;
public string DefaultGivenDeclension { get; init; } = NameGrammar.Hard;
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
public IReadOnlyList<SurnameEntry> Surnames { get; init; } = [];
}
+298
View File
@@ -0,0 +1,298 @@
namespace HSchool.People;
internal static class FamilyFactory
{
public static (Family Family, List<Person> Members) Create(
DefCatalog catalog,
NameSetDef names,
int schoolSeed,
FamilyPlan plan,
DateTime yearStart,
DateTime asOf)
{
var rng = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.AppearanceSalt));
var familyId = $"f{plan.FamilyIndex}";
var surname = names.Surnames[rng.Next(names.Surnames.Count)];
var childDrafts = new ChildDraft[plan.Seats.Count];
var usedGiven = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < plan.Seats.Count; i++)
{
var seat = plan.Seats[i];
var female = rng.Next(2) == 0;
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng, usedGiven);
var (first, last) = SchoolYears.BirthWindow(yearStart, seat.Year);
childDrafts[i] = new ChildDraft(seat, female, given, SchoolYears.RandomInRange(rng, first, last));
}
var fatherGiven = PickGiven(names.MaleGiven, rng);
var motherGiven = PickGiven(names.FemaleGiven, rng);
var fatherPatronymicSource = PickGiven(names.MaleGiven, rng);
var motherPatronymicSource = PickGiven(names.MaleGiven, rng);
DateTime motherBirth;
DateTime fatherBirth;
if (childDrafts.Length == 0)
{
motherBirth = asOf.AddYears(-(28 + rng.Next(28))).AddDays(-rng.Next(365));
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
}
else
{
var oldestBirth = childDrafts[0].Birth;
foreach (var child in childDrafts)
{
if (child.Birth < oldestBirth)
{
oldestBirth = child.Birth;
}
}
var motherYearsOlder = 22 + rng.Next(17);
motherBirth = oldestBirth.AddYears(-motherYearsOlder).AddDays(-rng.Next(30));
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
}
var members = new List<Person>();
var fatherId = $"{familyId}.p0";
var motherId = $"{familyId}.p1";
members.Add(
RollAdult(
catalog,
names,
rng,
fatherId,
familyId,
female: false,
fatherBirth,
asOf,
surname,
fatherGiven,
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
isParent: childDrafts.Length > 0));
members.Add(
RollAdult(
catalog,
names,
rng,
motherId,
familyId,
female: true,
motherBirth,
asOf,
surname,
motherGiven,
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
isParent: childDrafts.Length > 0));
var childIds = new List<string>(childDrafts.Length);
for (var i = 0; i < childDrafts.Length; i++)
{
var draft = childDrafts[i];
var id = $"{familyId}.c{i}";
childIds.Add(id);
members.Add(
RollChild(
catalog,
names,
rng,
id,
familyId,
draft,
asOf,
surname,
fatherGiven.Form));
}
var family = new Family(familyId, [fatherId, motherId], childIds);
return (family, members);
}
public static (Family Family, List<Person> Members) CreateStaffOnly(
DefCatalog catalog,
NameSetDef names,
int schoolSeed,
int familyIndex,
DateTime asOf)
{
var empty = new FamilyPlan(familyIndex, []);
return Create(catalog, names, schoolSeed, empty, SchoolYears.StartOn(asOf), asOf);
}
private static Person RollAdult(
DefCatalog catalog,
NameSetDef names,
Random rng,
string id,
string familyId,
bool female,
DateTime birth,
DateTime asOf,
SurnameEntry surname,
GivenNameEntry given,
string patronymic,
bool isParent)
{
var age = SchoolYears.AgeYears(birth, asOf);
var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff };
return FinishPerson(
catalog,
names,
rng,
id,
familyId,
female,
birth,
asOf,
age,
surname,
given,
patronymic,
roles,
isStudent: false,
isParent,
classId: null);
}
private static Person RollChild(
DefCatalog catalog,
NameSetDef names,
Random rng,
string id,
string familyId,
ChildDraft draft,
DateTime asOf,
SurnameEntry surname,
string fatherGiven)
{
var age = SchoolYears.AgeYears(draft.Birth, asOf);
var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule);
return FinishPerson(
catalog,
names,
rng,
id,
familyId,
draft.Female,
draft.Birth,
asOf,
age,
surname,
draft.Given,
patronymic,
[PersonRoles.Student],
isStudent: true,
isParent: false,
draft.Seat.ClassId);
}
private static Person FinishPerson(
DefCatalog catalog,
NameSetDef names,
Random rng,
string id,
string familyId,
bool female,
DateTime birth,
DateTime asOf,
int age,
SurnameEntry surname,
GivenNameEntry given,
string patronymic,
IReadOnlyList<string> roles,
bool isStudent,
bool isParent,
string? classId)
{
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
var traits = PersonSampler.Traits(catalog, rng, roles, age);
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
var needs = PersonSampler.Needs(catalog);
var name = new PersonName(
given.Form,
female ? surname.Female : surname.Male,
patronymic,
GivenTable(given, names.DefaultGivenDeclension),
SurnameTable(surname, female, names.DefaultSurnameDeclension),
PatronymicTable(patronymic, female));
return new Person
{
Id = id,
FamilyId = familyId,
Female = female,
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
Name = name,
IsStudent = isStudent,
IsStaff = false,
IsParent = isParent,
ClassId = classId,
Numbers = numbers,
Choices = choices,
Skills = skills,
Traits = traits,
Needs = needs,
};
}
private static GivenNameEntry PickGiven(
IReadOnlyList<GivenNameEntry> pool,
Random rng,
HashSet<string>? used = null)
{
if (pool.Count == 0)
{
throw new InvalidOperationException("Name set has no given names for this sex.");
}
for (var attempt = 0; attempt < 16; attempt++)
{
var pick = pool[rng.Next(pool.Count)];
if (used is null || used.Add(pick.Form) || used.Count >= pool.Count)
{
return pick;
}
}
return pool[rng.Next(pool.Count)];
}
private static CaseTable GivenTable(GivenNameEntry entry, string defaultModel) =>
new()
{
Nom = NameGrammar.InflectGiven(entry, GrammaticalCase.Nominative, defaultModel),
Gen = NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, defaultModel),
Dat = NameGrammar.InflectGiven(entry, GrammaticalCase.Dative, defaultModel),
Acc = NameGrammar.InflectGiven(entry, GrammaticalCase.Accusative, defaultModel),
Ins = NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, defaultModel),
Pre = NameGrammar.InflectGiven(entry, GrammaticalCase.Prepositional, defaultModel),
};
private static CaseTable SurnameTable(SurnameEntry entry, bool female, string defaultModel) =>
new()
{
Nom = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Nominative, defaultModel),
Gen = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Genitive, defaultModel),
Dat = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Dative, defaultModel),
Acc = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Accusative, defaultModel),
Ins = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Instrumental, defaultModel),
Pre = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Prepositional, defaultModel),
};
private static CaseTable PatronymicTable(string nominative, bool female) =>
new()
{
Nom = nominative,
Gen = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Genitive),
Dat = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Dative),
Acc = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Accusative),
Ins = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Instrumental),
Pre = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Prepositional),
};
private readonly record struct ChildDraft(
PupilSeat Seat,
bool Female,
GivenNameEntry Given,
DateTime Birth);
}
+47
View File
@@ -0,0 +1,47 @@
namespace HSchool.People;
internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilSeat> Seats);
/// <summary>
/// Walks seats in order and groups them into families of 13. A short remainder becomes
/// singleton families rather than shrinking an earlier family's intended size, so a longer
/// seat list only appends families.
/// </summary>
internal static class FamilyPlanner
{
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats)
{
var plans = new List<FamilyPlan>();
var offset = 0;
var index = 0;
while (offset < seats.Count)
{
var remaining = seats.Count - offset;
var want = ChildCount(schoolSeed, index);
var take = want <= remaining ? want : 1;
var slice = new PupilSeat[take];
for (var i = 0; i < take; i++)
{
slice[i] = seats[offset + i];
}
plans.Add(new FamilyPlan(index, slice));
offset += take;
index++;
}
return plans;
}
public static int ChildCount(int schoolSeed, int familyIndex)
{
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.ChildCountSalt));
var roll = rng.Next(100);
if (roll < 50)
{
return 1;
}
return roll < 85 ? 2 : 3;
}
}
+1
View File
@@ -0,0 +1 @@
global using HSchool.Content;
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.People</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HSchool.People.Tests" />
</ItemGroup>
</Project>
+354
View File
@@ -0,0 +1,354 @@
namespace HSchool.People;
internal static class PersonSampler
{
public static (Dictionary<string, int> Numbers, Dictionary<string, string> Choices) Body(
DefCatalog catalog,
Random rng,
bool female,
int age)
{
var numbers = new Dictionary<string, int>(StringComparer.Ordinal);
var choices = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var def in catalog.BodyAttributes.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (def.Abstract)
{
continue;
}
if (def.Kind == BodyAttributeKind.Number)
{
numbers[def.DefName] = SampleNumber(def, rng, female, age);
}
else
{
choices[def.DefName] = SampleChoice(def, rng, female, age);
}
}
var height = numbers.GetValueOrDefault("Height", 170);
var weight = numbers.GetValueOrDefault("Weight", 65);
choices[BodyBuilds.Attribute] = BodyBuilds.FromHeightAndWeight(height, weight);
return (numbers, choices);
}
public static Dictionary<string, int> Skills(
DefCatalog catalog,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
IReadOnlyList<string> traits)
{
var values = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (skill.Abstract)
{
continue;
}
var mean = MeanForAge(skill, age);
var stdDev = skill.Distribution?.StdDev ?? 10f;
var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
rolled = Clamp(rolled, skill.Range);
rolled = ApplyBodyLimits(rolled, skill, choices);
rolled = Clamp(rolled, skill.Range);
values[skill.DefName] = rolled;
}
foreach (var traitName in traits)
{
if (!catalog.Traits.TryGetValue(traitName, out var trait))
{
continue;
}
foreach (var modifier in trait.SkillModifiers)
{
if (!values.TryGetValue(modifier.Skill, out var current)
|| !catalog.Skills.TryGetValue(modifier.Skill, out var skill))
{
continue;
}
values[modifier.Skill] = Clamp(current + modifier.Offset, skill.Range);
}
}
return values;
}
public static List<string> Traits(DefCatalog catalog, Random rng, IReadOnlyList<string> roles, int age)
{
var picked = new List<string>();
var count = rng.Next(3);
if (count == 0 || catalog.Traits.Count == 0)
{
return picked;
}
var pool = catalog.Traits.Values
.Where(trait => !trait.Abstract && TraitFits(trait, roles, age))
.OrderBy(trait => trait.DefName, StringComparer.Ordinal)
.ToList();
for (var n = 0; n < count && pool.Count > 0; n++)
{
var chosen = WeightedTrait(pool, rng);
if (chosen is null)
{
break;
}
picked.Add(chosen.DefName);
var banned = catalog.TraitIncompatibilities(chosen.DefName);
pool.RemoveAll(trait =>
trait.DefName == chosen.DefName
|| banned.Contains(trait.DefName));
}
picked.Sort(StringComparer.Ordinal);
return picked;
}
public static Dictionary<string, float> Needs(DefCatalog catalog)
{
var values = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var need in catalog.Needs.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (!need.Abstract)
{
values[need.DefName] = need.Initial;
}
}
return values;
}
internal static int ApplyBodyLimits(int value, SkillDef skill, IReadOnlyDictionary<string, string> choices)
{
foreach (var limit in skill.BodyLimits)
{
if (!choices.TryGetValue(limit.Attribute, out var actual))
{
continue;
}
if (limit.Value is not null && !actual.Equals(limit.Value, StringComparison.Ordinal))
{
continue;
}
if (limit.Min is { } min)
{
value = Math.Max(value, min);
}
if (limit.Max is { } max)
{
value = Math.Min(value, max);
}
}
return value;
}
private static bool TraitFits(TraitDef trait, IReadOnlyList<string> roles, int age)
{
if (trait.Age is { } range && (age < range.Min || age > range.Max))
{
return false;
}
if (trait.Roles.Count == 0)
{
return true;
}
foreach (var role in trait.Roles)
{
foreach (var have in roles)
{
if (role.Equals(have, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static TraitDef? WeightedTrait(List<TraitDef> pool, Random rng)
{
var total = 0;
foreach (var trait in pool)
{
total += Math.Max(trait.Weight, 1);
}
if (total <= 0)
{
return null;
}
var pick = rng.Next(total);
foreach (var trait in pool)
{
pick -= Math.Max(trait.Weight, 1);
if (pick < 0)
{
return trait;
}
}
return pool[^1];
}
private static int SampleNumber(BodyAttributeDef def, Random rng, bool female, int age)
{
var row = MatchDistribution(def.Distributions, female, age);
var mean = row?.Distribution.Mean ?? 0;
var stdDev = row?.Distribution.StdDev ?? 1;
var value = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
if (row?.Range is { } range)
{
return Clamp(value, range);
}
return value;
}
private static string SampleChoice(BodyAttributeDef def, Random rng, bool female, int age)
{
var options = def.Options.Where(option => OptionFits(option, female, age)).ToList();
if (options.Count == 0)
{
options = [.. def.Options];
}
var total = 0;
foreach (var option in options)
{
total += Math.Max(option.Weight, 1);
}
var pick = rng.Next(Math.Max(total, 1));
foreach (var option in options)
{
pick -= Math.Max(option.Weight, 1);
if (pick < 0)
{
return option.Value;
}
}
return options[^1].Value;
}
private static bool OptionFits(WeightedOption option, bool female, int age)
{
if (option.AgeMin is { } min && age < min)
{
return false;
}
if (option.AgeMax is { } max && age > max)
{
return false;
}
if (option.Sex is null)
{
return true;
}
var want = female ? "female" : "male";
return option.Sex.Equals(want, StringComparison.OrdinalIgnoreCase);
}
private static SexAgeDistribution? MatchDistribution(
IReadOnlyList<SexAgeDistribution> rows,
bool female,
int age)
{
SexAgeDistribution? unisex = null;
foreach (var row in rows)
{
if (row.AgeMin is { } min && age < min)
{
continue;
}
if (row.AgeMax is { } max && age > max)
{
continue;
}
if (row.Sex is null)
{
unisex ??= row;
continue;
}
var want = female ? "female" : "male";
if (row.Sex.Equals(want, StringComparison.OrdinalIgnoreCase))
{
return row;
}
}
return unisex ?? (rows.Count == 0 ? null : rows[0]);
}
private static float MeanForAge(SkillDef skill, int age)
{
if (skill.AgeMeans.Count == 0)
{
return skill.Distribution?.Mean ?? (skill.Range.Min + skill.Range.Max) / 2f;
}
var points = skill.AgeMeans.OrderBy(point => point.Age).ToList();
if (age <= points[0].Age)
{
return points[0].Mean;
}
if (age >= points[^1].Age)
{
return points[^1].Mean;
}
for (var i = 1; i < points.Count; i++)
{
if (age > points[i].Age)
{
continue;
}
var a = points[i - 1];
var b = points[i];
var span = b.Age - a.Age;
var t = span == 0 ? 0f : (age - a.Age) / (float)span;
return a.Mean + (t * (b.Mean - a.Mean));
}
return points[^1].Mean;
}
private static int Clamp(int value, IntRange range) => Math.Clamp(value, range.Min, range.Max);
private static double NextGaussian(Random rng)
{
double u1;
do
{
u1 = rng.NextDouble();
}
while (u1 <= double.Epsilon);
var u2 = rng.NextDouble();
return Math.Sqrt(-2d * Math.Log(u1)) * Math.Cos(2d * Math.PI * u2);
}
}
+65
View File
@@ -0,0 +1,65 @@
namespace HSchool.People;
/// <summary>Plain roster data. Simulation turns these into entities; this project does not.</summary>
public sealed record Roster(
IReadOnlyList<Person> People,
IReadOnlyList<Family> Families,
IReadOnlyList<SchoolClass> Classes);
public sealed record Person
{
public required string Id { get; init; }
public required string FamilyId { get; init; }
public required bool Female { get; init; }
public required DateTime BirthDate { get; init; }
public required PersonName Name { get; init; }
public required bool IsStudent { get; init; }
public required bool IsStaff { get; init; }
public required bool IsParent { get; init; }
public string? ClassId { get; init; }
public string? Position { get; init; }
public string? WorkplaceRoomId { get; init; }
public required IReadOnlyDictionary<string, int> Numbers { get; init; }
public required IReadOnlyDictionary<string, string> Choices { get; init; }
public required IReadOnlyDictionary<string, int> Skills { get; init; }
public required IReadOnlyList<string> Traits { get; init; }
public required IReadOnlyDictionary<string, float> Needs { get; init; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
public sealed record PersonName(
string Given,
string Surname,
string Patronymic,
CaseTable GivenCases,
CaseTable SurnameCases,
CaseTable PatronymicCases);
public sealed record Family(
string Id,
IReadOnlyList<string> ParentIds,
IReadOnlyList<string> ChildIds);
public sealed record SchoolClass(
string Id,
int Year,
string Letter,
string RoomId,
int Capacity,
IReadOnlyList<string> PupilIds);
+127
View File
@@ -0,0 +1,127 @@
namespace HSchool.People;
/// <summary>
/// Builds a roster from a catalog, a map and a school seed. Deterministic: same inputs, same people.
/// </summary>
public static class RosterGenerator
{
/// <summary>
/// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in
/// the default school year. Callers with a live clock must pass <paramref name="asOf"/>.
/// </summary>
public static readonly DateTime DefaultAsOf = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
public static Roster Generate(
DefCatalog catalog,
MapLayout map,
int schoolSeed,
string nameSetId,
DateTime? asOf = null)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId);
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc);
var yearStart = SchoolYears.StartOn(when);
var demand = SchoolDemand.From(catalog, map);
var plans = FamilyPlanner.Plan(schoolSeed, demand.Seats);
var people = new List<Person>();
var families = new List<Family>(plans.Count);
foreach (var plan in plans)
{
var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when);
families.Add(family);
people.AddRange(members);
}
var nextFamily = plans.Count;
while (people.Count(person => !person.IsStudent) < demand.Staff.Count)
{
var (family, members) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
families.Add(family);
people.AddRange(members);
nextFamily++;
}
var staffed = AssignStaff(people, demand.Staff);
var classes = FillClasses(demand.Classes, staffed);
return new Roster(staffed, families, classes);
}
private static IReadOnlyList<Person> AssignStaff(List<Person> people, IReadOnlyList<StaffOpening> openings)
{
if (openings.Count == 0)
{
return people;
}
var jobs = new Dictionary<string, StaffOpening>(StringComparer.Ordinal);
var index = 0;
foreach (var person in people)
{
if (person.IsStudent || index >= openings.Count)
{
continue;
}
jobs[person.Id] = openings[index];
index++;
}
if (jobs.Count == 0)
{
return people;
}
var result = new Person[people.Count];
for (var i = 0; i < people.Count; i++)
{
var person = people[i];
result[i] = jobs.TryGetValue(person.Id, out var job)
? person with
{
IsStaff = true,
Position = job.Position,
WorkplaceRoomId = job.RoomId,
}
: person;
}
return result;
}
private static IReadOnlyList<SchoolClass> FillClasses(
IReadOnlyList<SchoolClass> classes,
IReadOnlyList<Person> people)
{
var pupils = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var schoolClass in classes)
{
pupils[schoolClass.Id] = new List<string>(schoolClass.Capacity);
}
foreach (var person in people)
{
if (person.ClassId is { } classId && pupils.TryGetValue(classId, out var list))
{
list.Add(person.Id);
}
}
var filled = new SchoolClass[classes.Count];
for (var i = 0; i < classes.Count; i++)
{
var schoolClass = classes[i];
filled[i] = schoolClass with { PupilIds = pupils[schoolClass.Id] };
}
return filled;
}
}
+85
View File
@@ -0,0 +1,85 @@
namespace HSchool.People;
public readonly record struct StaffOpening(string RoomId, string Position);
public readonly record struct PupilSeat(string ClassId, string RoomId, int Year, string Letter);
/// <summary>
/// How many pupils and staff a map asks for. Classrooms are rooms with pupil slots; each one
/// becomes a roster class. Positions come from <see cref="RoomDef.Positions"/>, not the wire labels.
/// </summary>
public sealed class SchoolDemand
{
internal SchoolDemand(
IReadOnlyList<SchoolClass> classes,
IReadOnlyList<PupilSeat> seats,
IReadOnlyList<StaffOpening> staff)
{
Classes = classes;
Seats = seats;
Staff = staff;
}
public IReadOnlyList<SchoolClass> Classes { get; }
public IReadOnlyList<PupilSeat> Seats { get; }
public IReadOnlyList<StaffOpening> Staff { get; }
public static SchoolDemand From(DefCatalog catalog, MapLayout map)
{
var classrooms = new List<(RoomNode Room, int Slots)>();
var staff = new List<StaffOpening>();
foreach (var room in map.Rooms)
{
var slots = PupilSlotsOf(catalog, room);
if (slots > 0)
{
classrooms.Add((room, slots));
}
if (catalog.Rooms.TryGetValue(room.Def, out var def))
{
foreach (var position in def.Positions)
{
staff.Add(new StaffOpening(room.Id, position));
}
}
}
const string letters = "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ";
var classes = new SchoolClass[classrooms.Count];
var seats = new List<PupilSeat>();
for (var i = 0; i < classrooms.Count; i++)
{
var (room, capacity) = classrooms[i];
var year = (i % 11) + 1;
var letterIndex = i / 11;
var letter = letterIndex < letters.Length ? letters[letterIndex].ToString() : "?";
var classId = $"class-{room.Id}";
classes[i] = new SchoolClass(classId, year, letter, room.Id, capacity, []);
for (var seat = 0; seat < capacity; seat++)
{
seats.Add(new PupilSeat(classId, room.Id, year, letter));
}
}
return new SchoolDemand(classes, seats, staff);
}
private static int PupilSlotsOf(DefCatalog catalog, RoomNode room)
{
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
var count = fill.Count < 1 ? 1 : Math.Min(fill.Count, byte.MaxValue);
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * count;
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace HSchool.People;
/// <summary>
/// Game-calendar helpers. Takes the caller's date so this library never reads the wall clock.
/// </summary>
internal static class SchoolYears
{
public static DateTime StartOn(DateTime asOf)
{
var utc = DateTime.SpecifyKind(asOf, DateTimeKind.Utc);
var year = utc.Month >= 9 ? utc.Year : utc.Year - 1;
return new DateTime(year, 9, 1, 0, 0, 0, DateTimeKind.Utc);
}
/// <summary>
/// Grade N pupils turned N+6 on or before 1 September of the current school year.
/// </summary>
public static (DateTime First, DateTime Last) BirthWindow(DateTime yearStart, int grade)
{
var ageOnStart = grade + 6;
var last = yearStart.AddYears(-ageOnStart);
var first = last.AddYears(-1).AddDays(1);
return (first, last);
}
public static int AgeYears(DateTime birth, DateTime asOf)
{
var age = asOf.Year - birth.Year;
if (birth.Date.AddYears(age) > asOf.Date)
{
age--;
}
return Math.Max(age, 0);
}
public static DateTime RandomInRange(Random rng, DateTime first, DateTime last)
{
var days = (last.Date - first.Date).Days;
var offset = days <= 0 ? 0 : rng.Next(days + 1);
return DateTime.SpecifyKind(first.Date.AddDays(offset), DateTimeKind.Utc);
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace HSchool.People;
/// <summary>
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
/// so appending a thirteenth family leaves the first twelve unchanged.
/// </summary>
internal static class Seed
{
public const int ChildCountSalt = 1;
public const int AppearanceSalt = 2;
public static int Mix(int schoolSeed, int familyIndex, int salt)
{
var z = Mix64((uint)schoolSeed);
z = Mix64(z ^ (uint)(familyIndex + 1));
z = Mix64(z ^ (uint)(salt + 1));
return (int)z;
}
private static ulong Mix64(ulong z)
{
z += 0x9E3779B97F4A7C15UL;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
return z ^ (z >> 31);
}
}
+2
View File
@@ -86,6 +86,7 @@ internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
MapLayout DefaultMap)
{
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
@@ -95,6 +96,7 @@ internal sealed record CatalogResponse(
Placeable(catalog.Floors.Values, catalog, locale),
PlaceableRooms(catalog, locale),
PlaceableThings(catalog, locale),
Placeable(catalog.NameSets.Values, catalog, locale),
map);
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
+9 -1
View File
@@ -50,6 +50,7 @@ internal static class SchoolEndpoints
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds,
request.Map,
request.NameSetId,
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
@@ -71,6 +72,8 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
SchoolCreationError.UnknownNameSet =>
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
_ => Results.Problem("Unknown error."),
};
})
@@ -107,7 +110,12 @@ internal static class SchoolEndpoints
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate, IReadOnlyList<string>? ModIds, MapLayout? Map);
internal sealed record CreateSchoolRequest(
string? Name,
DateTime StartDate,
IReadOnlyList<string>? ModIds,
MapLayout? Map,
string? NameSetId);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
+1
View File
@@ -14,6 +14,7 @@ internal abstract record GameCommand
DateTime StartDate,
IReadOnlyList<string>? ExtraModIds,
MapLayout? Map,
string? NameSetId,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
+48 -3
View File
@@ -216,10 +216,28 @@ internal sealed class GameLoopService(
return;
}
DefCatalog catalog;
try
{
catalog = mods.LoadCatalog(packIds);
}
catch (Exception ex) when (ex is ContentLoadException or SchoolContentUnavailableException)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
var nameSetId = ResolveNameSetId(catalog, command.NameSetId);
if (nameSetId is null)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet));
return;
}
var id = _nextId++;
store.WriteNextId(_nextId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId);
Track(worker);
worker.Start();
@@ -385,7 +403,8 @@ internal sealed class GameLoopService(
save.SpeedIndex,
isNew: false,
save.ModIds,
save.Map);
save.Map,
save.NameSetId);
worker.Start();
try
@@ -431,7 +450,8 @@ internal sealed class GameLoopService(
int speedIndex,
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? map) =>
MapLayout? map,
string? nameSetId) =>
new(
id,
name,
@@ -441,6 +461,7 @@ internal sealed class GameLoopService(
isNew,
modIds,
map,
nameSetId,
_options,
clients,
metrics,
@@ -449,6 +470,30 @@ internal sealed class GameLoopService(
onFailed: schoolId => commands.Enqueue(new GameCommand.WorkerFailed(schoolId)),
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
/// <summary>
/// Empty request uses the first placeable set (core's Slavic). A named id must exist in the
/// catalog already loaded for this pack list — unknown extras were rejected above.
/// </summary>
internal static string? ResolveNameSetId(DefCatalog catalog, string? requested)
{
var available = catalog.NameSets.Values
.Where(def => !def.Abstract)
.Select(def => def.DefName)
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
if (available.Length == 0)
{
return null;
}
if (string.IsNullOrWhiteSpace(requested))
{
return available[0];
}
return available.Contains(requested, StringComparer.Ordinal) ? requested : null;
}
private void Track(SchoolWorker worker)
{
_workers[worker.Id] = worker;
+3
View File
@@ -23,6 +23,8 @@ internal sealed class SchoolSave
public IReadOnlyList<string>? ModIds { get; init; }
public MapLayout? Map { get; init; }
public string? NameSetId { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -152,6 +154,7 @@ internal sealed class SchoolStore
SpeedIndex = save.SpeedIndex,
ModIds = save.ModIds,
Map = save.Map,
NameSetId = save.NameSetId,
});
}
catch (Exception ex)
+4
View File
@@ -29,6 +29,7 @@ internal sealed class SchoolWorker
private readonly bool _isNew;
private readonly IReadOnlyList<string>? _modIds;
private readonly MapLayout? _savedMap;
private readonly string? _nameSetId;
private readonly Action<int> _onFailed;
private readonly int _id;
@@ -53,6 +54,7 @@ internal sealed class SchoolWorker
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? savedMap,
string? nameSetId,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -69,6 +71,7 @@ internal sealed class SchoolWorker
_isNew = isNew;
_modIds = modIds;
_savedMap = savedMap;
_nameSetId = nameSetId;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -411,6 +414,7 @@ internal sealed class SchoolWorker
SpeedIndex = school.Clock.SpeedIndex,
ModIds = school.Catalog?.PackIds,
Map = school.Map,
NameSetId = _nameSetId,
});
}
catch (Exception ex)
@@ -0,0 +1,11 @@
{
"defName": "EyeColor",
"kind": "choice",
"options": [
{ "value": "BrownEyes", "weight": 40 },
{ "value": "BlueEyes", "weight": 20 },
{ "value": "GrayEyes", "weight": 20 },
{ "value": "GreenEyes", "weight": 12 },
{ "value": "HazelEyes", "weight": 8 },
],
}
@@ -0,0 +1,11 @@
{
"defName": "HairColor",
"kind": "choice",
"options": [
{ "value": "BlackHair", "weight": 25 },
{ "value": "BrownHair", "weight": 40 },
{ "value": "BlondHair", "weight": 20 },
{ "value": "RedHair", "weight": 8 },
{ "value": "GrayHair", "weight": 2, "ageMin": 40 },
],
}
@@ -0,0 +1,14 @@
{
"defName": "Height",
"kind": "number",
"distributions": [
{ "sex": "male", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 128, "stdDev": 6 }, "range": { "min": 110, "max": 150 } },
{ "sex": "male", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 150, "stdDev": 8 }, "range": { "min": 128, "max": 175 } },
{ "sex": "male", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 172, "stdDev": 8 }, "range": { "min": 150, "max": 195 } },
{ "sex": "male", "ageMin": 18, "distribution": { "mean": 176, "stdDev": 7 }, "range": { "min": 155, "max": 205 } },
{ "sex": "female", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 127, "stdDev": 6 }, "range": { "min": 108, "max": 148 } },
{ "sex": "female", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 148, "stdDev": 7 }, "range": { "min": 128, "max": 170 } },
{ "sex": "female", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 163, "stdDev": 6 }, "range": { "min": 145, "max": 185 } },
{ "sex": "female", "ageMin": 18, "distribution": { "mean": 164, "stdDev": 6 }, "range": { "min": 145, "max": 190 } },
],
}
@@ -0,0 +1,14 @@
{
"defName": "Weight",
"kind": "number",
"distributions": [
{ "sex": "male", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 27, "stdDev": 5 }, "range": { "min": 16, "max": 45 } },
{ "sex": "male", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 42, "stdDev": 8 }, "range": { "min": 25, "max": 70 } },
{ "sex": "male", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 62, "stdDev": 10 }, "range": { "min": 40, "max": 95 } },
{ "sex": "male", "ageMin": 18, "distribution": { "mean": 78, "stdDev": 12 }, "range": { "min": 50, "max": 130 } },
{ "sex": "female", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 26, "stdDev": 5 }, "range": { "min": 15, "max": 44 } },
{ "sex": "female", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 42, "stdDev": 8 }, "range": { "min": 24, "max": 70 } },
{ "sex": "female", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 54, "stdDev": 8 }, "range": { "min": 38, "max": 85 } },
{ "sex": "female", "ageMin": 18, "distribution": { "mean": 62, "stdDev": 10 }, "range": { "min": 42, "max": 110 } },
],
}
@@ -0,0 +1,131 @@
{
"defName": "Slavic",
"patronymicRule": "slavic",
"defaultGivenDeclension": "hard",
"defaultSurnameDeclension": "ov",
"maleGiven": [
{ "form": "Александр" },
{ "form": "Алексей" },
{ "form": "Андрей" },
{ "form": "Антон" },
{ "form": "Артём" },
{ "form": "Борис" },
{ "form": "Вадим" },
{ "form": "Валерий" },
{ "form": "Виктор" },
{ "form": "Виталий" },
{ "form": "Владимир" },
{ "form": "Владислав" },
{ "form": "Григорий" },
{ "form": "Денис" },
{ "form": "Дмитрий" },
{ "form": "Евгений" },
{ "form": "Егор" },
{ "form": "Иван" },
{ "form": "Игорь", "declension": "soft" },
{ "form": "Илья", "declension": "ya" },
{ "form": "Кирилл" },
{ "form": "Константин" },
{ "form": "Максим" },
{ "form": "Михаил" },
{ "form": "Никита", "declension": "a" },
{ "form": "Николай" },
{ "form": "Олег" },
{ "form": "Павел" },
{ "form": "Пётр" },
{ "form": "Роман" },
{ "form": "Сергей" },
{ "form": "Станислав" },
{ "form": "Степан" },
{ "form": "Фёдор" },
{ "form": "Юрий" },
],
"femaleGiven": [
{ "form": "Александра", "declension": "a" },
{ "form": "Алина", "declension": "a" },
{ "form": "Анастасия", "declension": "iya" },
{ "form": "Анна", "declension": "a" },
{ "form": "Валентина", "declension": "a" },
{ "form": "Валерия", "declension": "iya" },
{ "form": "Вера", "declension": "a" },
{ "form": "Виктория", "declension": "iya" },
{ "form": "Дарья", "declension": "ya" },
{ "form": "Екатерина", "declension": "a" },
{ "form": "Елена", "declension": "a" },
{ "form": "Елизавета", "declension": "a" },
{ "form": "Ирина", "declension": "a" },
{ "form": "Ксения", "declension": "iya" },
{
"form": "Любовь",
"cases": {
"nom": "Любовь",
"gen": "Любови",
"dat": "Любови",
"acc": "Любовь",
"ins": "Любовью",
"pre": "Любови",
},
},
{ "form": "Людмила", "declension": "a" },
{ "form": "Маргарита", "declension": "a" },
{ "form": "Мария", "declension": "iya" },
{ "form": "Надежда", "declension": "a" },
{ "form": "Наталья", "declension": "ya" },
{ "form": "Нина", "declension": "a" },
{ "form": "Оксана", "declension": "a" },
{ "form": "Ольга", "declension": "a" },
{ "form": "Полина", "declension": "a" },
{ "form": "Светлана", "declension": "a" },
{ "form": "София", "declension": "iya" },
{ "form": "Татьяна", "declension": "a" },
{ "form": "Юлия", "declension": "iya" },
{ "form": "Яна", "declension": "a" },
{ "form": "Вероника", "declension": "a" },
{ "form": "Диана", "declension": "a" },
{ "form": "Марина", "declension": "a" },
],
"surnames": [
{ "male": "Иванов", "female": "Иванова" },
{ "male": "Петров", "female": "Петрова" },
{ "male": "Смирнов", "female": "Смирнова" },
{ "male": "Кузнецов", "female": "Кузнецова" },
{ "male": "Попов", "female": "Попова" },
{ "male": "Васильев", "female": "Васильева" },
{ "male": "Соколов", "female": "Соколова" },
{ "male": "Михайлов", "female": "Михайлова" },
{ "male": "Новиков", "female": "Новикова" },
{ "male": "Фёдоров", "female": "Фёдорова" },
{ "male": "Морозов", "female": "Морозова" },
{ "male": "Волков", "female": "Волкова" },
{ "male": "Алексеев", "female": "Алексеева" },
{ "male": "Лебедев", "female": "Лебедева" },
{ "male": "Семёнов", "female": "Семёнова" },
{ "male": "Егоров", "female": "Егорова" },
{ "male": "Павлов", "female": "Павлова" },
{ "male": "Козлов", "female": "Козлова" },
{ "male": "Степанов", "female": "Степанова" },
{ "male": "Николаев", "female": "Николаева" },
{ "male": "Орлов", "female": "Орлова" },
{ "male": "Андреев", "female": "Андреева" },
{ "male": "Макаров", "female": "Макарова" },
{ "male": "Никитин", "female": "Никитина", "declension": "in" },
{ "male": "Захаров", "female": "Захарова" },
{ "male": "Зайцев", "female": "Зайцева" },
{ "male": "Соловьёв", "female": "Соловьёва" },
{ "male": "Борисов", "female": "Борисова" },
{ "male": "Яковлев", "female": "Яковлева" },
{ "male": "Григорьев", "female": "Григорьева" },
{ "male": "Романов", "female": "Романова" },
{ "male": "Воробьёв", "female": "Воробьёва" },
{ "male": "Сергеев", "female": "Сергеева" },
{ "male": "Кузьмин", "female": "Кузьмина", "declension": "in" },
{ "male": "Фролов", "female": "Фролова" },
{ "male": "Александров", "female": "Александрова" },
{ "male": "Дмитриев", "female": "Дмитриева" },
{ "male": "Королёв", "female": "Королёва" },
{ "male": "Громов", "female": "Громова" },
{ "male": "Ильин", "female": "Ильина", "declension": "in" },
{ "male": "Козловский", "female": "Козловская", "declension": "sky" },
{ "male": "Орловский", "female": "Орловская", "declension": "sky" },
],
}
@@ -0,0 +1,6 @@
[
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Social", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
]
@@ -0,0 +1,69 @@
[
{
"defName": "Mathematics",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"ageMeans": [
{ "age": 7, "mean": 28 },
{ "age": 17, "mean": 55 },
],
},
{
"defName": "RussianLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 52, "stdDev": 13 },
"ageMeans": [
{ "age": 7, "mean": 32 },
{ "age": 17, "mean": 56 },
],
},
{
"defName": "Literature",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
},
{
"defName": "Physics",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 15 },
"ageMeans": [
{ "age": 12, "mean": 30 },
{ "age": 17, "mean": 50 },
],
},
{
"defName": "History",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
},
{
"defName": "PhysicalEducation",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 55, "stdDev": 14 },
},
{
"defName": "Biology",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
},
{
"defName": "Chemistry",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 15 },
},
{
"defName": "Geography",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 12 },
},
{
"defName": "Informatics",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 16 },
},
{
"defName": "ForeignLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 15 },
},
]
@@ -0,0 +1,30 @@
[
{
"defName": "Agility",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"bodyLimits": [
{ "attribute": "Build", "value": "Obese", "max": 25 },
{ "attribute": "Build", "value": "Heavy", "max": 45 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
{
"defName": "Strength",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"bodyLimits": [
{ "attribute": "Build", "value": "Skinny", "max": 45 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
{
"defName": "Endurance",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
"bodyLimits": [
{ "attribute": "Build", "value": "Obese", "max": 35 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
]
@@ -0,0 +1,78 @@
[
{
"defName": "Diligent",
"weight": 8,
"incompatible": ["Lazy", "AbsentMinded"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": 8 },
{ "skill": "RussianLanguage", "offset": 6 },
],
},
{
"defName": "AbsentMinded",
"weight": 6,
"incompatible": ["Diligent"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": -6 },
{ "skill": "Informatics", "offset": -4 },
],
},
{
"defName": "Bully",
"weight": 4,
"incompatible": ["Kind", "Quiet"],
"roles": ["student"],
"age": { "min": 7, "max": 18 },
"skillModifiers": [
{ "skill": "Strength", "offset": 8 },
{ "skill": "Literature", "offset": -4 },
],
},
{
"defName": "Quiet",
"weight": 7,
"incompatible": ["Leader", "Bully"],
},
{
"defName": "Leader",
"weight": 4,
"incompatible": ["Quiet"],
"skillModifiers": [
{ "skill": "History", "offset": 4 },
],
},
{
"defName": "Lazy",
"weight": 6,
"incompatible": ["Diligent"],
"skillModifiers": [
{ "skill": "PhysicalEducation", "offset": -8 },
{ "skill": "Mathematics", "offset": -6 },
],
},
{
"defName": "Curious",
"weight": 7,
"skillModifiers": [
{ "skill": "Physics", "offset": 6 },
{ "skill": "Informatics", "offset": 6 },
],
},
{
"defName": "HotTempered",
"weight": 5,
"incompatible": ["Quiet"],
},
{
"defName": "Kind",
"weight": 8,
"incompatible": ["Bully"],
},
{
"defName": "Neat",
"weight": 6,
"skillModifiers": [
{ "skill": "Chemistry", "offset": 4 },
],
},
]
@@ -44,4 +44,52 @@
"ComputerLab": "Computer lab",
"GymHall": "Gym",
"ChangingRoom": "Changing room",
"Mathematics": "Mathematics",
"RussianLanguage": "Russian",
"Literature": "Literature",
"Physics": "Physics",
"History": "History",
"PhysicalEducation": "PE",
"Biology": "Biology",
"Chemistry": "Chemistry",
"Geography": "Geography",
"Informatics": "Informatics",
"ForeignLanguage": "Foreign language",
"Agility": "Agility",
"Strength": "Strength",
"Endurance": "Endurance",
"Diligent": "Diligent",
"AbsentMinded": "Absent-minded",
"Bully": "Bully",
"Quiet": "Quiet",
"Leader": "Leader",
"Lazy": "Lazy",
"Curious": "Curious",
"HotTempered": "Hot-tempered",
"Kind": "Kind",
"Neat": "Neat",
"Height": "Height",
"Weight": "Weight",
"HairColor": "Hair colour",
"EyeColor": "Eye colour",
"BlackHair": "Black",
"BrownHair": "Brown",
"BlondHair": "Blond",
"RedHair": "Red",
"GrayHair": "Grey",
"BrownEyes": "Brown",
"BlueEyes": "Blue",
"GrayEyes": "Grey",
"GreenEyes": "Green",
"HazelEyes": "Hazel",
"Sleep": "Sleep",
"Hunger": "Hunger",
"Toilet": "Toilet",
"Social": "Social",
"Slavic": "Slavic",
"Skinny": "Skinny",
"Average": "Average",
"Athletic": "Athletic",
"Heavy": "Heavy",
"Obese": "Obese",
}
@@ -44,4 +44,52 @@
"ComputerLab": "Компьютерный класс",
"GymHall": "Спортивный зал",
"ChangingRoom": "Раздевалка",
"Mathematics": "Математика",
"RussianLanguage": "Русский язык",
"Literature": "Литература",
"Physics": "Физика",
"History": "История",
"PhysicalEducation": "Физкультура",
"Biology": "Биология",
"Chemistry": "Химия",
"Geography": "География",
"Informatics": "Информатика",
"ForeignLanguage": "Иностранный язык",
"Agility": "Ловкость",
"Strength": "Сила",
"Endurance": "Выносливость",
"Diligent": "Усидчивый",
"AbsentMinded": "Рассеянный",
"Bully": "Задира",
"Quiet": "Тихоня",
"Leader": "Лидер",
"Lazy": "Лентяй",
"Curious": "Любопытный",
"HotTempered": "Вспыльчивый",
"Kind": "Добрый",
"Neat": "Аккуратный",
"Height": "Рост",
"Weight": "Вес",
"HairColor": "Цвет волос",
"EyeColor": "Цвет глаз",
"BlackHair": "Чёрные",
"BrownHair": "Каштановые",
"BlondHair": "Русые",
"RedHair": "Рыжие",
"GrayHair": "Седые",
"BrownEyes": "Карие",
"BlueEyes": "Голубые",
"GrayEyes": "Серые",
"GreenEyes": "Зелёные",
"HazelEyes": "Ореховые",
"Sleep": "Сон",
"Hunger": "Голод",
"Toilet": "Туалет",
"Social": "Общение",
"Slavic": "Славянский",
"Skinny": "Худощавое",
"Average": "Обычное",
"Athletic": "Атлетическое",
"Heavy": "Плотное",
"Obese": "Полное",
}
+1
View File
@@ -10,6 +10,7 @@ public enum SchoolCreationError
InvalidMap,
UnknownMod,
InvalidCatalog,
UnknownNameSet,
}
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>