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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user