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
+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)