Dress people at generation from a separate apparel stream.
Wardrobes live on the person in people.json: everyday layers, textbooks per parallel, and chance-based bags from catalog data. Hauling and the carry cap are defs, not constants. The golden roster gains an items column; later family members shift because Hauling consumes extra appearance rolls. Old saves without items are dressed on load. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HSchool.AppHost.Tests;
|
||||
|
||||
[Collection(AppHostCollection.Name)]
|
||||
public class InventoryApiTests(AppHostFixture fixture)
|
||||
{
|
||||
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public async Task Card_IncludesWornAndCarried_ListDoesNot()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var school = await SchoolApiTests.CreateAsync(client, "Гардероб карточка", Start, seed: 31);
|
||||
|
||||
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||
$"/api/schools/{school.Id}/people?role=student&year=5&pageSize=20",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(page);
|
||||
var girl = page.People.First(person => person.Female);
|
||||
|
||||
using var listResponse = await client.GetAsync(
|
||||
$"/api/schools/{school.Id}/people?pageSize=1",
|
||||
TestContext.Current.CancellationToken);
|
||||
var listJson = await listResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain("carryMass", listJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"worn\"", listJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"carried\"", listJson, StringComparison.Ordinal);
|
||||
|
||||
var card = await client.GetFromJsonAsync<Card>(
|
||||
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(girl.Id)}?lang=ru",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(card);
|
||||
Assert.NotEmpty(card.Worn);
|
||||
Assert.Contains(card.Worn, item => item.Layers.Count > 0 && item.ColorLabel is { Length: > 0 });
|
||||
Assert.True(card.CarryCapacity > 0);
|
||||
Assert.True(card.CarryMass <= card.CarryCapacity);
|
||||
Assert.Contains(card.Carried, item => item.Mass > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameSeed_YieldsTheSameShirt()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var first = await SchoolApiTests.CreateAsync(client, "Синяя рубашка 1", Start, seed: 31);
|
||||
var shirt = await FirstGirlTopAsync(client, first.Id);
|
||||
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var second = await SchoolApiTests.CreateAsync(client, "Синяя рубашка 2", Start, seed: 31);
|
||||
var again = await FirstGirlTopAsync(client, second.Id);
|
||||
|
||||
Assert.Equal(shirt.DefName, again.DefName);
|
||||
Assert.Equal(shirt.Color, again.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeopleJsonWithoutInventory_IsDressedOnReloadAndRewritten()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var school = await SchoolApiTests.CreateAsync(client, "Старый сейв без одежды", Start, seed: 31);
|
||||
var before = await FirstGirlTopAsync(client, school.Id);
|
||||
|
||||
var directory = await SavesDirectoryAsync(client);
|
||||
var path = Path.Combine(directory, $"{school.Id}.people.json");
|
||||
StripItems(path);
|
||||
Assert.DoesNotContain("\"items\"", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
|
||||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||
reload.EnsureSuccessStatusCode();
|
||||
|
||||
var after = await FirstGirlTopAsync(client, school.Id);
|
||||
Assert.Equal(before.DefName, after.DefName);
|
||||
Assert.Equal(before.Color, after.Color);
|
||||
Assert.Contains("\"items\"", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<WornItem> FirstGirlTopAsync(HttpClient client, int schoolId)
|
||||
{
|
||||
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||
$"/api/schools/{schoolId}/people?role=student&sex=female&pageSize=20",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(page);
|
||||
Assert.NotEmpty(page.People);
|
||||
var girl = page.People[0];
|
||||
var card = await client.GetFromJsonAsync<Card>(
|
||||
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(girl.Id)}?lang=ru",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(card);
|
||||
var top = card.Worn.FirstOrDefault(item =>
|
||||
item.Layers.Any(layer => layer.DefName.Equals("Top", StringComparison.Ordinal)));
|
||||
Assert.NotNull(top);
|
||||
return top;
|
||||
}
|
||||
|
||||
private static async Task<string> SavesDirectoryAsync(HttpClient client)
|
||||
{
|
||||
var payload = await client.GetFromJsonAsync<SavesDirectoryResponse>(
|
||||
"/api/dev/saves-directory",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
return payload.Path;
|
||||
}
|
||||
|
||||
private static void StripItems(string path)
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(path))
|
||||
?? throw new InvalidOperationException("People file parsed to nothing.");
|
||||
foreach (var person in root["people"]?.AsArray() ?? [])
|
||||
{
|
||||
person?.AsObject().Remove("items");
|
||||
}
|
||||
|
||||
var applicants = root["applicants"]?["applicants"]?.AsArray();
|
||||
if (applicants is not null)
|
||||
{
|
||||
foreach (var applicant in applicants)
|
||||
{
|
||||
applicant?["person"]?.AsObject()?.Remove("items");
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
path,
|
||||
root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private sealed record SavesDirectoryResponse(string Path);
|
||||
|
||||
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
||||
|
||||
private sealed record PersonRow(string Id, string FullName, bool Female);
|
||||
|
||||
private sealed record Card(
|
||||
IReadOnlyList<WornItem> Worn,
|
||||
IReadOnlyList<CarriedItem> Carried,
|
||||
float CarryMass,
|
||||
float CarryCapacity);
|
||||
|
||||
private sealed record WornItem(
|
||||
string DefName,
|
||||
string Label,
|
||||
string? Color,
|
||||
string? ColorLabel,
|
||||
IReadOnlyList<Layer> Layers);
|
||||
|
||||
private sealed record Layer(string DefName, string Label);
|
||||
|
||||
private sealed record CarriedItem(string DefName, string Label, float Mass);
|
||||
}
|
||||
@@ -7,6 +7,10 @@ than a new roll.
|
||||
These files live forever: a new save field must load `current/` and must not reshuffle
|
||||
`legacy-no-native/`. A real format change adds a new folder; it does not rewrite the old one.
|
||||
|
||||
Phase 31 rewrote `current/1.people.json` because people are born dressed (`items` on each
|
||||
person, plus `Hauling`). `legacy-no-native/` is left as it was: no inventory, so load still
|
||||
has to dress them and must not reshuffle names.
|
||||
|
||||
Regenerate after an intentional roster change (same commit):
|
||||
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user