diff --git a/docs/design/inventory.md b/docs/design/inventory.md index 74a9ee0..a6caed0 100644 --- a/docs/design/inventory.md +++ b/docs/design/inventory.md @@ -142,11 +142,12 @@ ### Слои -Код знает девять, в `core` все используются: +Код знает десять, в `core` все используются: | Слой | Что надевают | | --- | --- | | `Underwear` | трусы | +| `UpperUnderwear` | бюстгальтер (поверх трусов, под верх) | | `Socks` | носки | | `Bottom` | юбка, штаны, джинсы, брюки | | `Top` | футболка, рубашка | @@ -166,7 +167,8 @@ Не витрина моды, а достаточный гардероб, чтобы генератор и правила было чем кормить: -- бельё и носки нескольких цветов; +- бельё: абстрактный `Underwear`, от него трусы / боксеры / трусики и бюстгальтеры (`UpperUnderwear`); +- носки нескольких цветов; - низы: юбка (женская), штаны, джинсы, брюки — длины обычная и короткая; - верх: футболка, рубашка; - поверх: свитер; diff --git a/src/HSchool.Content/ApparelDefs.cs b/src/HSchool.Content/ApparelDefs.cs index d3d421e..1ac121d 100644 --- a/src/HSchool.Content/ApparelDefs.cs +++ b/src/HSchool.Content/ApparelDefs.cs @@ -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 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); } /// diff --git a/src/HSchool.Content/Appropriateness.cs b/src/HSchool.Content/Appropriateness.cs index 0e28910..3325085 100644 --- a/src/HSchool.Content/Appropriateness.cs +++ b/src/HSchool.Content/Appropriateness.cs @@ -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); } diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 7afe9d8..fbf2598 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -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(StringComparer.Ordinal); foreach (var color in thing.Colors) { diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index c6bc4e7..33039c1 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -78,6 +78,12 @@ public sealed class ThingDef : Def /// Layers this occupies when worn. Empty — not apparel, so it can sit on the map. public IReadOnlyList Layers { get; init; } = []; + /// + /// Worn layers fully hidden beneath this piece while condition stays above torn. + /// Example: pants list . + /// + public IReadOnlyList FullyCoversLayers { get; init; } = []; + /// Can live in a bag, locker or at home. Furniture stays false. public bool Portable { get; init; } diff --git a/src/HSchool.People/DressGenerator.cs b/src/HSchool.People/DressGenerator.cs index ae30818..120941c 100644 --- a/src/HSchool.People/DressGenerator.cs +++ b/src/HSchool.People/DressGenerator.cs @@ -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]; diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index afaa4e8..59758ba 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -90,7 +90,8 @@ internal sealed record WornItemResponse( string? ColorLabel, IReadOnlyList Layers, float Condition, - string ConditionLabel); + string ConditionLabel, + IReadOnlyList FullyCoversLayers); internal sealed record CarriedItemResponse( string DefName, diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 8dfac56..3fd69cd 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -26,9 +26,11 @@ internal sealed class GameLoopService( private readonly SimulationOptions _options = options.Value; private readonly SchoolNameGenerator _names = new(); private readonly Dictionary _workers = []; + private readonly Dictionary _incompatible = []; private readonly List _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(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( } /// - /// 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 reload-schools 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. /// 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(); + 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) diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index fb66f50..5c19a6f 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -278,9 +278,11 @@ internal static class PersonCardReader } IReadOnlyList layerIds = []; + IReadOnlyList 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; diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index bbd4b3e..29ddb08 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -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)) diff --git a/src/HSchool.Server/Game/PortraitVisibleWorn.cs b/src/HSchool.Server/Game/PortraitVisibleWorn.cs new file mode 100644 index 0000000..604478d --- /dev/null +++ b/src/HSchool.Server/Game/PortraitVisibleWorn.cs @@ -0,0 +1,50 @@ +using HSchool.Content; +using HSchool.Server.Api; + +namespace HSchool.Server.Game; + +/// Skips worn items fully hidden under another piece that still covers them. +internal static class PortraitVisibleWorn +{ + /// Same threshold as the «worn» band — torn and rags may show what is underneath. + private const float FullCoverMinCondition = 0.4f; + + public static IEnumerable Filter(IReadOnlyList 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 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; +} diff --git a/src/HSchool.Server/Game/SchoolState.cs b/src/HSchool.Server/Game/SchoolState.cs index 2645eb4..cc2c6c2 100644 --- a/src/HSchool.Server/Game/SchoolState.cs +++ b/src/HSchool.Server/Game/SchoolState.cs @@ -11,7 +11,8 @@ internal sealed record SchoolState( bool Running, byte SpeedIndex, IReadOnlyList ModIds, - int Seed); + int Seed, + bool Incompatible = false); /// Everything the main menu needs in one read. internal sealed record SchoolsState(int MaxSchools, IReadOnlyList Schools); diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 3c36cd0..6ca6c6f 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -30,9 +30,6 @@ internal sealed class SchoolSave public string? ClimatePresetId { get; init; } - /// Legacy field. Format 2 and older; mapped to on load. - public string? NameSetId { get; init; } - public string? NativeLanguage { get; init; } public IReadOnlyList? 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; } - /// - /// 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. - /// - 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, - }; - } + /// Older and newer files stay on disk for the menu to delete; they never start. + public static bool CanStart(SchoolSave save) => + save.Format == CurrentFormat && !string.IsNullOrWhiteSpace(save.CountryId); public void Save(SchoolSave save) { diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index e066858..d09a9e5 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -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); diff --git a/src/HSchool.Server/mods/core/defs/things/apparel.jsonc b/src/HSchool.Server/mods/core/defs/things/apparel.jsonc index 70fe54f..4bb3fec 100644 --- a/src/HSchool.Server/mods/core/defs/things/apparel.jsonc +++ b/src/HSchool.Server/mods/core/defs/things/apparel.jsonc @@ -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, diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index f88bf3f..ac97782 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -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", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index f7bdfaf..0c5630c 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -19,6 +19,13 @@ "MedicalCouch": "Кушетка", "Locker": "Шкафчик", "Underwear": "Бельё", + "UpperUnderwear": "Верхнее бельё", + "Briefs": "Трусы", + "Boxers": "Боксеры", + "Panties": "Трусики", + "Bra": "Бюстгальтер", + "SportsBra": "Спортивный бюстгальтер", + "SoftBra": "Мягкий бюстгальтер", "Socks": "Носки", "Skirt": "Юбка", "ShortSkirt": "Короткая юбка", diff --git a/src/HSchool.Simulation/ApparelDresser.cs b/src/HSchool.Simulation/ApparelDresser.cs index 09892d5..298257d 100644 --- a/src/HSchool.Simulation/ApparelDresser.cs +++ b/src/HSchool.Simulation/ApparelDresser.cs @@ -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(); var occupied = new HashSet(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(); diff --git a/src/HSchool.Simulation/ApparelWear.cs b/src/HSchool.Simulation/ApparelWear.cs index cf21fd5..70b2ad9 100644 --- a/src/HSchool.Simulation/ApparelWear.cs +++ b/src/HSchool.Simulation/ApparelWear.cs @@ -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) { diff --git a/tests/HSchool.Content.Tests/ApparelDefTests.cs b/tests/HSchool.Content.Tests/ApparelDefTests.cs index 99e2e98..2d8b4ca 100644 --- a/tests/HSchool.Content.Tests/ApparelDefTests.cs +++ b/tests/HSchool.Content.Tests/ApparelDefTests.cs @@ -115,6 +115,45 @@ public class ApparelDefTests Assert.Equal([ColorTags.Bright], catalog.Colors["Red"].Tags); } + [Fact] + public void BraInheritsFromUnderwear_ButOccupiesUpperUnderwear() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "colors", + "white", + """{ "defName": "White", "tags": ["neutral"] }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "underwear", + """{ "defName": "Underwear", "abstract": true, "layers": ["Underwear"], "portable": true, "mass": 0.05, "colors": ["White"] }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "bra", + """{ "defName": "Bra", "parent": "Underwear", "layers": ["UpperUnderwear"], "sex": "female" }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "panties", + """{ "defName": "Panties", "parent": "Underwear", "sex": "female" }"""), + ]); + + Assert.True(catalog.Things["Underwear"].Abstract); + Assert.False(catalog.Things["Bra"].Abstract); + Assert.Equal("Underwear", catalog.Things["Bra"].Parent); + Assert.Equal([ApparelLayers.UpperUnderwear], catalog.Things["Bra"].Layers); + Assert.True(catalog.Things["Bra"].Portable); + Assert.Equal(0.05f, catalog.Things["Bra"].Mass); + Assert.Equal(["White"], catalog.Things["Bra"].Colors); + Assert.Equal([ApparelLayers.Underwear], catalog.Things["Panties"].Layers); + Assert.Equal("female", catalog.Things["Panties"].Sex); + } + [Fact] public void TShirtReferencingMissingRed_FailsTheCatalog() { diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 542fcfb..57c69ad 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -41,6 +41,15 @@ public class VanillaCoreTests Assert.False(catalog.Things["Chair"].Portable); Assert.Empty(catalog.Things["StudentDesk"].Layers); Assert.Equal([ApparelLayers.Bottom, ApparelLayers.Top], catalog.Things["Dress"].Layers); + Assert.True(catalog.Things["Underwear"].Abstract); + Assert.Equal("Underwear", catalog.Things["Panties"].Parent); + Assert.Equal("Underwear", catalog.Things["Bra"].Parent); + Assert.Equal([ApparelLayers.Underwear], catalog.Things["Panties"].Layers); + Assert.Equal([ApparelLayers.UpperUnderwear], catalog.Things["Bra"].Layers); + Assert.Equal("female", catalog.Things["Bra"].Sex); + Assert.Equal(11, catalog.Things["Bra"].Age?.Min); + Assert.Contains(ApparelLayers.UpperUnderwear, catalog.Things["Shirt"].FullyCoversLayers); + Assert.DoesNotContain(ApparelLayers.UpperUnderwear, catalog.Things["Pants"].FullyCoversLayers); Assert.Contains("Red", catalog.Things["TShirt"].Colors); Assert.True(catalog.Things["PeShirt"].Pe); Assert.True(catalog.Things["Textbook"].Portable); diff --git a/tests/HSchool.People.Tests/DressGeneratorTests.cs b/tests/HSchool.People.Tests/DressGeneratorTests.cs index 93b1a27..52ce8b5 100644 --- a/tests/HSchool.People.Tests/DressGeneratorTests.cs +++ b/tests/HSchool.People.Tests/DressGeneratorTests.cs @@ -136,6 +136,43 @@ public class DressGeneratorTests Assert.Equal(ItemsSnapshot(roster), ItemsSnapshot(dressed)); } + [Fact] + public void Underwear_IsSplitBySex_AndAdultFemalesWearABra() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + var bras = new HashSet(StringComparer.Ordinal) { "Bra", "SportsBra", "SoftBra" }; + + foreach (var person in roster.People) + { + var worn = person.Items + .Where(item => item.Location == ItemLocations.Worn) + .Select(item => item.Def) + .ToHashSet(StringComparer.Ordinal); + + Assert.DoesNotContain("Underwear", worn); + if (person.Female) + { + Assert.Contains("Panties", worn); + Assert.DoesNotContain("Briefs", worn); + Assert.DoesNotContain("Boxers", worn); + if (person.AgeOn(Fixtures.AsOf) >= 11) + { + Assert.True(worn.Overlaps(bras), $"{person.Id} age {person.AgeOn(Fixtures.AsOf)} has no bra."); + } + else + { + Assert.False(worn.Overlaps(bras), $"{person.Id} is too young for a bra."); + } + } + else + { + Assert.True(worn.Contains("Briefs") || worn.Contains("Boxers"), $"{person.Id} has no male underwear."); + Assert.DoesNotContain("Panties", worn); + Assert.False(worn.Overlaps(bras), $"{person.Id} wears a bra."); + } + } + } + [Fact] public void EveryoneHasHauling() { diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 3a4d5b9..a41cb00 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -1,3 +1,4 @@ +using HSchool.Content; using HSchool.Server.Api; using HSchool.Server.Game; @@ -101,7 +102,7 @@ public class PortraitPromptBuilderTests null, null, new PersonFamilyResponse([], [], [], []), - [new WornItemResponse("Shirt", "Shirt", "White", "White", [], 1f, "new")], + [new WornItemResponse("Shirt", "Shirt", "White", "White", [new DefLabelResponse(ApparelLayers.Top, "Top")], 1f, "new", [ApparelLayers.Underwear])], [], 0f, 0f); diff --git a/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs b/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs new file mode 100644 index 0000000..65f7cfe --- /dev/null +++ b/tests/HSchool.Server.Tests/PortraitVisibleWornTests.cs @@ -0,0 +1,160 @@ +using HSchool.Content; +using HSchool.Server.Api; +using HSchool.Server.Game; + +namespace HSchool.Server.Tests; + +public class PortraitVisibleWornTests +{ + [Fact] + public void Filter_ExcludesUnderwearUnderIntactPants() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Underwear", ApparelLayers.Underwear, covers: []), + Item("Pants", ApparelLayers.Bottom, covers: [ApparelLayers.Underwear]), + ]).Select(item => item.DefName).ToList(); + + Assert.Equal(["Pants"], visible); + } + + [Fact] + public void Filter_KeepsUnderwearWhenPantsAreTorn() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Underwear", ApparelLayers.Underwear, covers: [], condition: 1f), + Item("Pants", ApparelLayers.Bottom, covers: [ApparelLayers.Underwear], condition: 0.2f), + ]).Select(item => item.DefName).ToList(); + + Assert.Contains("Underwear", visible); + Assert.Contains("Pants", visible); + } + + [Fact] + public void Filter_ExcludesBraUnderIntactShirt() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Bra", ApparelLayers.UpperUnderwear, covers: []), + Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear, ApparelLayers.UpperUnderwear]), + ]).Select(item => item.DefName).ToList(); + + Assert.Equal(["Shirt"], visible); + } + + [Fact] + public void Filter_KeepsBraWhenShirtIsTorn() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Bra", ApparelLayers.UpperUnderwear, covers: [], condition: 1f), + Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear, ApparelLayers.UpperUnderwear], condition: 0.2f), + ]).Select(item => item.DefName).ToList(); + + Assert.Contains("Bra", visible); + Assert.Contains("Shirt", visible); + } + + [Fact] + public void Filter_KeepsBraWhenOnlyPantsCoverUnderwear() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Panties", ApparelLayers.Underwear, covers: []), + Item("Bra", ApparelLayers.UpperUnderwear, covers: []), + Item("Pants", ApparelLayers.Bottom, covers: [ApparelLayers.Underwear]), + ]).Select(item => item.DefName).ToList(); + + Assert.DoesNotContain("Panties", visible); + Assert.Contains("Bra", visible); + Assert.Contains("Pants", visible); + } + + [Fact] + public void Filter_KeepsShirtWithPants() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear]), + Item("Pants", ApparelLayers.Bottom, covers: [ApparelLayers.Underwear]), + ]).Select(item => item.DefName).ToList(); + + Assert.Contains("Shirt", visible); + Assert.Contains("Pants", visible); + } + + [Fact] + public void Filter_ExcludesShirtUnderSweater() + { + var visible = PortraitVisibleWorn.Filter( + [ + Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear]), + Item("Sweater", ApparelLayers.OverTop, covers: [ApparelLayers.Underwear, ApparelLayers.Top]), + ]).Select(item => item.DefName).ToList(); + + Assert.Equal(["Sweater"], visible); + } + + [Fact] + public void Build_OmitsCoveredShirtFromPrompt() + { + var preset = SwarmUiPresetDefinition.CreateDefault(); + preset.Positive = "School photo."; + var card = SampleCard( + [ + Item("Shirt", ApparelLayers.Top, covers: [ApparelLayers.Underwear], color: "White", colorLabel: "White"), + Item("Coat", ApparelLayers.Outer, covers: [ApparelLayers.Underwear, ApparelLayers.Top, ApparelLayers.OverTop, ApparelLayers.Bottom], color: "Black", colorLabel: "Black"), + ]); + + var (positive, _) = PortraitPromptBuilder.Build(card, preset.ToProfile(PortraitKind.Avatar), PortraitKind.Avatar); + + Assert.Contains("coat", positive, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shirt", positive, StringComparison.OrdinalIgnoreCase); + } + + private static WornItemResponse Item( + string defName, + string layer, + IReadOnlyList covers, + string? color = null, + string? colorLabel = null, + float condition = 1f) => + new( + defName, + defName, + color, + colorLabel, + [new DefLabelResponse(layer, layer)], + condition, + "new", + covers); + + private static PersonCardResponse SampleCard(IReadOnlyList worn) => + new( + "f0.c0", + "Maria Ivanova", + "Ivanova", + "Maria", + "", + true, + 12, + new DateTime(2000, 3, 14, 0, 0, 0, DateTimeKind.Utc), + ["student"], + 5, + "A", + "class-1", + null, + null, + [], + [], + [], + [], + null, + null, + new PersonFamilyResponse([], [], [], []), + worn, + [], + 0f, + 0f); +}