Enhance apparel definitions: Introduce 'UpperUnderwear' layer and related items (Bra, SportsBra, SoftBra) to support gender-specific clothing. Update inventory and dress generation logic to accommodate new layers and ensure proper coverage. Adjust localization for new apparel terms and refine tests for underwear functionality.

This commit is contained in:
Leonid Pershin
2026-08-20 07:20:40 +03:00
parent 2af85e1c41
commit 660340c216
24 changed files with 527 additions and 108 deletions
+7 -1
View File
@@ -7,6 +7,7 @@ namespace HSchool.Content;
public static class ApparelLayers
{
public const string Underwear = "Underwear";
public const string UpperUnderwear = "UpperUnderwear";
public const string Socks = "Socks";
public const string Bottom = "Bottom";
public const string Top = "Top";
@@ -17,10 +18,15 @@ public static class ApparelLayers
public const string Accessory = "Accessory";
public static readonly IReadOnlyList<string> All =
[Underwear, Socks, Bottom, Top, OverTop, Outer, Shoes, Head, Accessory];
[Underwear, UpperUnderwear, Socks, Bottom, Top, OverTop, Outer, Shoes, Head, Accessory];
public static bool IsKnown(string layer) =>
All.Any(candidate => candidate.Equals(layer, StringComparison.Ordinal));
public static bool IsUnder(string layer) =>
layer.Equals(Underwear, StringComparison.Ordinal)
|| layer.Equals(UpperUnderwear, StringComparison.Ordinal)
|| layer.Equals(Socks, StringComparison.Ordinal);
}
/// <summary>
+1 -3
View File
@@ -213,7 +213,5 @@ public static class Appropriateness
}
private static bool IsUnderLayer(ThingDef def) =>
def.Layers.All(layer =>
layer.Equals(ApparelLayers.Underwear, StringComparison.Ordinal)
|| layer.Equals(ApparelLayers.Socks, StringComparison.Ordinal));
def.Layers.All(ApparelLayers.IsUnder);
}
+8
View File
@@ -628,6 +628,14 @@ public sealed class CatalogLoader
}
}
foreach (var covered in thing.FullyCoversLayers)
{
if (!ApparelLayers.IsKnown(covered))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' fullyCoversLayers has unknown layer '{covered}'.");
}
}
var colors = new HashSet<string>(StringComparer.Ordinal);
foreach (var color in thing.Colors)
{
+6
View File
@@ -78,6 +78,12 @@ public sealed class ThingDef : Def
/// <summary>Layers this occupies when worn. Empty — not apparel, so it can sit on the map.</summary>
public IReadOnlyList<string> Layers { get; init; } = [];
/// <summary>
/// Worn layers fully hidden beneath this piece while condition stays above torn.
/// Example: pants list <see cref="ApparelLayers.Underwear"/>.
/// </summary>
public IReadOnlyList<string> FullyCoversLayers { get; init; } = [];
/// <summary>Can live in a bag, locker or at home. Furniture stays false.</summary>
public bool Portable { get; init; }
+8 -1
View File
@@ -175,7 +175,14 @@ public static class DressGenerator
}
private static readonly string[] RequiredLayers =
[ApparelLayers.Underwear, ApparelLayers.Socks, ApparelLayers.Bottom, ApparelLayers.Top, ApparelLayers.Shoes];
[
ApparelLayers.Underwear,
ApparelLayers.UpperUnderwear,
ApparelLayers.Socks,
ApparelLayers.Bottom,
ApparelLayers.Top,
ApparelLayers.Shoes,
];
private static readonly string[] OptionalLayers =
[ApparelLayers.OverTop, ApparelLayers.Outer, ApparelLayers.Head, ApparelLayers.Accessory];
+2 -1
View File
@@ -90,7 +90,8 @@ internal sealed record WornItemResponse(
string? ColorLabel,
IReadOnlyList<DefLabelResponse> Layers,
float Condition,
string ConditionLabel);
string ConditionLabel,
IReadOnlyList<string> FullyCoversLayers);
internal sealed record CarriedItemResponse(
string DefName,
+82 -14
View File
@@ -26,9 +26,11 @@ internal sealed class GameLoopService(
private readonly SimulationOptions _options = options.Value;
private readonly SchoolNameGenerator _names = new();
private readonly Dictionary<int, SchoolWorker> _workers = [];
private readonly Dictionary<int, SchoolState> _incompatible = [];
private readonly List<int> _order = [];
private SchoolWorker[] _publishedWorkers = [];
private SchoolState[] _publishedIncompatible = [];
private int _currentTick;
private int _nextId = 1;
@@ -45,12 +47,16 @@ internal sealed class GameLoopService(
get
{
var workers = Volatile.Read(ref _publishedWorkers);
var schools = new SchoolState[workers.Length];
var broken = Volatile.Read(ref _publishedIncompatible);
var schools = new List<SchoolState>(workers.Length + broken.Length);
for (var i = 0; i < workers.Length; i++)
{
schools[i] = workers[i].Snapshot;
schools.Add(workers[i].Snapshot);
}
schools.AddRange(broken);
schools.Sort((left, right) => left.Id.CompareTo(right.Id));
return new SchoolsState(_options.MaxSchools, schools);
}
}
@@ -291,17 +297,17 @@ internal sealed class GameLoopService(
}
/// <summary>
/// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock
/// never moves again, and tell anybody watching it to go back to the menu. The save file stays
/// where it is — a restart or <c>reload-schools</c> is the way back.
/// A school's thread died. Keep a menu card so the player can delete the file instead of
/// opening a school that no longer ticks.
/// </summary>
private void HandleWorkerFailed(int schoolId)
{
if (!_workers.ContainsKey(schoolId))
if (!_workers.TryGetValue(schoolId, out var worker))
{
return;
}
RememberIncompatible(worker.Snapshot with { Incompatible = true, Running = false });
Untrack(schoolId);
foreach (var client in clients.All)
@@ -445,6 +451,15 @@ internal sealed class GameLoopService(
{
if (!_workers.TryGetValue(command.SchoolId, out var worker))
{
if (_incompatible.Remove(command.SchoolId))
{
store.Delete(command.SchoolId);
PublishWorkers();
logger.LogInformation("Incompatible school {SchoolId} deleted.", command.SchoolId);
command.Result.TrySetResult(true);
return;
}
command.Result.TrySetResult(false);
return;
}
@@ -554,17 +569,35 @@ internal sealed class GameLoopService(
_nextId = nextId;
if (saves.Count > _options.MaxSchools)
var startable = new List<SchoolSave>();
foreach (var save in saves)
{
logger.LogWarning(
"Found {Count} school saves but the limit is {Max}; starting the first {Max}.",
saves.Count,
_options.MaxSchools,
_options.MaxSchools);
saves = [.. saves.Take(_options.MaxSchools)];
if (SchoolStore.CanStart(save))
{
startable.Add(save);
}
else
{
RememberIncompatible(FromSave(save));
}
}
foreach (var save in saves)
if (startable.Count > _options.MaxSchools)
{
logger.LogWarning(
"Found {Count} runnable school saves but the limit is {Max}; starting the first {Max}.",
startable.Count,
_options.MaxSchools,
_options.MaxSchools);
foreach (var extra in startable.Skip(_options.MaxSchools))
{
RememberIncompatible(FromSave(extra));
}
startable = [.. startable.Take(_options.MaxSchools)];
}
foreach (var save in startable)
{
var worker = SpawnWorker(
save.Id,
@@ -596,9 +629,12 @@ internal sealed class GameLoopService(
save.Id,
save.Name);
await worker.StopAsync(persist: false).ConfigureAwait(false);
RememberIncompatible(FromSave(save));
}
}
PublishWorkers();
if (_workers.Count > 0)
{
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
@@ -610,6 +646,7 @@ internal sealed class GameLoopService(
var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray();
_workers.Clear();
_order.Clear();
_incompatible.Clear();
PublishWorkers();
if (stopping.Length > 0)
@@ -704,6 +741,37 @@ internal sealed class GameLoopService(
Volatile.Write(ref _publishedWorkers, snapshot);
metrics.SchoolsChanged(snapshot.Length);
Volatile.Write(
ref _publishedIncompatible,
[.. _incompatible.Values.OrderBy(school => school.Id)]);
}
private void RememberIncompatible(SchoolState school)
{
_incompatible[school.Id] = school with { Incompatible = true, Running = false };
}
private SchoolState FromSave(SchoolSave save) =>
new(
save.Id,
save.Name,
save.GameTime,
Running: false,
(byte)Math.Clamp(save.SpeedIndex, 0, 255),
save.ModIds ?? [],
SeedOf(save.Id),
Incompatible: true);
private int SeedOf(int schoolId)
{
try
{
return store.TryReadPeople(schoolId)?.Seed ?? 0;
}
catch (Exception)
{
return 0;
}
}
private static void SendSchoolGone(GameClient client, int schoolId)
+4 -1
View File
@@ -278,9 +278,11 @@ internal static class PersonCardReader
}
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
@@ -295,7 +297,8 @@ internal static class PersonCardReader
item.Condition,
catalog is null
? ApparelCondition.BandId(null, item.Condition)
: ApparelCondition.Label(catalog, locale, item.Condition)));
: ApparelCondition.Label(catalog, locale, item.Condition),
fullyCovers));
}
return rows;
@@ -39,7 +39,7 @@ internal static class PortraitPromptBuilder
parts.Add($"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}");
}
foreach (var item in card.Worn)
foreach (var item in PortraitVisibleWorn.Filter(card.Worn))
{
var color = item.ColorLabel ?? item.Color;
if (!string.IsNullOrWhiteSpace(color))
@@ -0,0 +1,50 @@
using HSchool.Content;
using HSchool.Server.Api;
namespace HSchool.Server.Game;
/// <summary>Skips worn items fully hidden under another piece that still covers them.</summary>
internal static class PortraitVisibleWorn
{
/// <summary>Same threshold as the «worn» band — torn and rags may show what is underneath.</summary>
private const float FullCoverMinCondition = 0.4f;
public static IEnumerable<WornItemResponse> Filter(IReadOnlyList<WornItemResponse> worn)
{
foreach (var item in worn)
{
if (item.Layers.Count == 0)
{
continue;
}
if (IsFullyHidden(item, worn))
{
continue;
}
yield return item;
}
}
private static bool IsFullyHidden(WornItemResponse item, IReadOnlyList<WornItemResponse> worn)
{
var layers = item.Layers.Select(layer => layer.DefName).ToArray();
foreach (var cover in worn)
{
if (ReferenceEquals(cover, item) || !CoversEffectively(cover))
{
continue;
}
if (layers.All(layer => cover.FullyCoversLayers.Contains(layer, StringComparer.Ordinal)))
{
return true;
}
}
return false;
}
private static bool CoversEffectively(WornItemResponse cover) => cover.Condition >= FullCoverMinCondition;
}
+2 -1
View File
@@ -11,7 +11,8 @@ internal sealed record SchoolState(
bool Running,
byte SpeedIndex,
IReadOnlyList<string> ModIds,
int Seed);
int Seed,
bool Incompatible = false);
/// <summary>Everything the main menu needs in one read.</summary>
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
+3 -55
View File
@@ -30,9 +30,6 @@ internal sealed class SchoolSave
public string? ClimatePresetId { get; init; }
/// <summary>Legacy field. Format 2 and older; mapped to <see cref="CountryId"/> on load.</summary>
public string? NameSetId { get; init; }
public string? NativeLanguage { get; init; }
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
@@ -149,21 +146,6 @@ internal sealed class SchoolStore
continue;
}
if (save.Format > CurrentFormat)
{
_logger.LogWarning(
"Save {Path} is format {Format}; this build reads format {Current}. Leaving the file in place.",
path,
save.Format,
CurrentFormat);
continue;
}
if (save.Format < CurrentFormat)
{
save = UpgradeOlderSave(save);
}
// Claimed last, so a file rejected above does not reserve an id a good file needs.
if (!claimed.TryAdd(save.Id, path))
{
@@ -187,7 +169,6 @@ internal sealed class SchoolStore
Map = save.Map,
CountryId = save.CountryId,
ClimatePresetId = save.ClimatePresetId,
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
DressRules = save.DressRules,
@@ -203,42 +184,9 @@ internal sealed class SchoolStore
return saves;
}
/// <summary>
/// Format 2 stored a name set. Slavic becomes Russia; any other id is tried as a country
/// (the example pack kept its defName). Climate is filled on the worker from the country's
/// first preset so the file is not rewritten until the school saves itself.
/// </summary>
internal static SchoolSave UpgradeOlderSave(SchoolSave save)
{
if (!string.IsNullOrWhiteSpace(save.CountryId))
{
return save;
}
var countryId = save.NameSetId switch
{
"Slavic" or null or "" => "Russia",
_ => save.NameSetId,
};
return new SchoolSave
{
Format = save.Format,
Id = save.Id,
Name = save.Name,
GameTime = save.GameTime,
Running = save.Running,
SpeedIndex = save.SpeedIndex,
ModIds = save.ModIds,
Map = save.Map,
CountryId = countryId,
ClimatePresetId = save.ClimatePresetId,
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
DressRules = save.DressRules,
};
}
/// <summary>Older and newer files stay on disk for the menu to delete; they never start.</summary>
public static bool CanStart(SchoolSave save) =>
save.Format == CurrentFormat && !string.IsNullOrWhiteSpace(save.CountryId);
public void Save(SchoolSave save)
{
+20 -25
View File
@@ -952,36 +952,31 @@ internal sealed class SchoolWorker
var loaded = _store.TryReadPeople(_id);
if (loaded is null)
{
seed = school.Id;
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;
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
{
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;
}
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;
if (DressGenerator.NeedsDressing(roster, applicants))
{
throw new SchoolContentUnavailableException(
$"School {_id} people have no clothes; the school was left unstarted.");
}
RequireKnownApparel(catalog, roster, applicants);
}
roster = LockerAssigner.Apply(catalog, map, roster);
@@ -1,12 +1,58 @@
[
{
"defName": "Underwear",
"abstract": true,
"layers": ["Underwear"],
"portable": true,
"mass": 0.05,
"insulation": 1,
"colors": ["White", "Black", "Gray"],
},
{
"defName": "Briefs",
"parent": "Underwear",
"sex": "male",
},
{
"defName": "Boxers",
"parent": "Underwear",
"sex": "male",
"mass": 0.06,
},
{
"defName": "Panties",
"parent": "Underwear",
"sex": "female",
"colors": ["White", "Black", "Gray", "Beige"],
},
{
"defName": "Bra",
"parent": "Underwear",
"layers": ["UpperUnderwear"],
"sex": "female",
"age": { "min": 11 },
"mass": 0.04,
"colors": ["White", "Black", "Beige", "Gray"],
},
{
"defName": "SportsBra",
"parent": "Underwear",
"layers": ["UpperUnderwear"],
"sex": "female",
"age": { "min": 11 },
"mass": 0.05,
"insulation": 2,
"colors": ["White", "Black", "Gray", "Blue"],
},
{
"defName": "SoftBra",
"parent": "Underwear",
"layers": ["UpperUnderwear"],
"sex": "female",
"age": { "min": 11 },
"mass": 0.03,
"colors": ["White", "Beige", "Gray"],
},
{
"defName": "Socks",
"layers": ["Socks"],
@@ -18,6 +64,7 @@
{
"defName": "Skirt",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.25,
"insulation": 2,
@@ -29,6 +76,7 @@
{
"defName": "ShortSkirt",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.2,
"insulation": 1,
@@ -40,6 +88,7 @@
{
"defName": "Pants",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.4,
"insulation": 3,
@@ -50,6 +99,7 @@
{
"defName": "Shorts",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.25,
"insulation": 1,
@@ -60,6 +110,7 @@
{
"defName": "Jeans",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.5,
"insulation": 3,
@@ -70,6 +121,7 @@
{
"defName": "Trousers",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.4,
"insulation": 3,
@@ -80,6 +132,7 @@
{
"defName": "TShirt",
"layers": ["Top"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear"],
"portable": true,
"mass": 0.15,
"insulation": 2,
@@ -89,6 +142,7 @@
{
"defName": "Shirt",
"layers": ["Top"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear"],
"portable": true,
"mass": 0.2,
"insulation": 2,
@@ -98,6 +152,7 @@
{
"defName": "Sweater",
"layers": ["OverTop"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear", "Top"],
"portable": true,
"mass": 0.4,
"insulation": 8,
@@ -107,6 +162,7 @@
{
"defName": "Jacket",
"layers": ["Outer"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear", "Top", "OverTop"],
"portable": true,
"mass": 1,
"insulation": 12,
@@ -116,6 +172,7 @@
{
"defName": "Coat",
"layers": ["Outer"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear", "Top", "OverTop", "Bottom"],
"portable": true,
"mass": 1.5,
"insulation": 18,
@@ -125,6 +182,7 @@
{
"defName": "FurCoat",
"layers": ["Outer"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear", "Top", "OverTop", "Bottom"],
"portable": true,
"mass": 2.5,
"insulation": 28,
@@ -134,6 +192,7 @@
{
"defName": "Shoes",
"layers": ["Shoes"],
"fullyCoversLayers": ["Socks"],
"portable": true,
"mass": 0.6,
"insulation": 2,
@@ -143,6 +202,7 @@
{
"defName": "WinterBoots",
"layers": ["Shoes"],
"fullyCoversLayers": ["Socks"],
"portable": true,
"mass": 1,
"insulation": 8,
@@ -178,6 +238,7 @@
{
"defName": "Dress",
"layers": ["Bottom", "Top"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear"],
"portable": true,
"mass": 0.4,
"insulation": 3,
@@ -189,6 +250,7 @@
{
"defName": "PeShirt",
"layers": ["Top"],
"fullyCoversLayers": ["Underwear", "UpperUnderwear"],
"portable": true,
"mass": 0.15,
"insulation": 2,
@@ -199,6 +261,7 @@
{
"defName": "PeShorts",
"layers": ["Bottom"],
"fullyCoversLayers": ["Underwear"],
"portable": true,
"mass": 0.2,
"insulation": 1,
@@ -19,6 +19,13 @@
"MedicalCouch": "Exam couch",
"Locker": "Locker",
"Underwear": "Underwear",
"UpperUnderwear": "Upper underwear",
"Briefs": "Briefs",
"Boxers": "Boxers",
"Panties": "Panties",
"Bra": "Bra",
"SportsBra": "Sports bra",
"SoftBra": "Soft bra",
"Socks": "Socks",
"Skirt": "Skirt",
"ShortSkirt": "Short skirt",
@@ -19,6 +19,13 @@
"MedicalCouch": "Кушетка",
"Locker": "Шкафчик",
"Underwear": "Бельё",
"UpperUnderwear": "Верхнее бельё",
"Briefs": "Трусы",
"Boxers": "Боксеры",
"Panties": "Трусики",
"Bra": "Бюстгальтер",
"SportsBra": "Спортивный бюстгальтер",
"SoftBra": "Мягкий бюстгальтер",
"Socks": "Носки",
"Skirt": "Юбка",
"ShortSkirt": "Короткая юбка",
+4 -1
View File
@@ -14,6 +14,7 @@ public static class ApparelDresser
private static readonly string[] LayerOrder =
[
ApparelLayers.Underwear,
ApparelLayers.UpperUnderwear,
ApparelLayers.Socks,
ApparelLayers.Bottom,
ApparelLayers.Top,
@@ -127,7 +128,9 @@ public static class ApparelDresser
var chosen = new List<InventoryItem>();
var occupied = new HashSet<string>(StringComparer.Ordinal);
var candidates = pool
.Where(entry => catalog.Things.TryGetValue(entry.Item.Def, out var def) && def.Layers.Count > 0)
.Where(entry => catalog.Things.TryGetValue(entry.Item.Def, out var def)
&& !def.Abstract
&& def.Layers.Count > 0)
.Select(entry => (Item: entry.Item, Def: catalog.Things[entry.Item.Def]))
.Where(entry => SexFits(entry.Def, context.Female) && AgeFits(entry.Def, context.Age))
.ToList();
+1 -1
View File
@@ -203,7 +203,7 @@ internal static class ApparelWear
}
private static bool IsApparel(DefCatalog catalog, string defName) =>
catalog.Things.TryGetValue(defName, out var def) && def.Layers.Count > 0;
catalog.Things.TryGetValue(defName, out var def) && !def.Abstract && def.Layers.Count > 0;
private static string? NearestColor(DefCatalog catalog, string defName, string? current)
{