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:
@@ -142,11 +142,12 @@
|
||||
|
||||
### Слои
|
||||
|
||||
Код знает девять, в `core` все используются:
|
||||
Код знает десять, в `core` все используются:
|
||||
|
||||
| Слой | Что надевают |
|
||||
| --- | --- |
|
||||
| `Underwear` | трусы |
|
||||
| `UpperUnderwear` | бюстгальтер (поверх трусов, под верх) |
|
||||
| `Socks` | носки |
|
||||
| `Bottom` | юбка, штаны, джинсы, брюки |
|
||||
| `Top` | футболка, рубашка |
|
||||
@@ -166,7 +167,8 @@
|
||||
|
||||
Не витрина моды, а достаточный гардероб, чтобы генератор и правила было чем кормить:
|
||||
|
||||
- бельё и носки нескольких цветов;
|
||||
- бельё: абстрактный `Underwear`, от него трусы / боксеры / трусики и бюстгальтеры (`UpperUnderwear`);
|
||||
- носки нескольких цветов;
|
||||
- низы: юбка (женская), штаны, джинсы, брюки — длины обычная и короткая;
|
||||
- верх: футболка, рубашка;
|
||||
- поверх: свитер;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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": "Короткая юбка",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>(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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> 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<WornItemResponse> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user