Merge branch 'main' into phase/32-weather-warmth
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # src/HSchool.Simulation/School.cs
This commit is contained in:
@@ -36,6 +36,7 @@ public class InventoryApiTests(AppHostFixture fixture)
|
||||
Assert.NotNull(card);
|
||||
Assert.NotEmpty(card.Worn);
|
||||
Assert.Contains(card.Worn, item => item.Layers.Count > 0 && item.ColorLabel is { Length: > 0 });
|
||||
Assert.Contains(card.Worn, item => item.Condition == 1 && item.ConditionLabel == "целая");
|
||||
Assert.True(card.CarryCapacity > 0);
|
||||
Assert.True(card.CarryMass <= card.CarryCapacity);
|
||||
Assert.Contains(card.Carried, item => item.Mass > 0);
|
||||
@@ -79,6 +80,27 @@ public class InventoryApiTests(AppHostFixture fixture)
|
||||
Assert.Contains("\"items\"", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Card_ConditionLabel_FollowsTheSavedThreshold()
|
||||
{
|
||||
using var client = fixture.App.CreateHttpClient("server");
|
||||
await SchoolApiTests.ResetAsync(client);
|
||||
var school = await SchoolApiTests.CreateAsync(client, "Ветхая рубашка", Start, seed: 31);
|
||||
var girl = await FirstGirlTopAsync(client, school.Id);
|
||||
Assert.Equal("целая", girl.ConditionLabel);
|
||||
|
||||
var directory = await SavesDirectoryAsync(client);
|
||||
var path = Path.Combine(directory, $"{school.Id}.people.json");
|
||||
SetFirstWornCondition(path, 0.1f);
|
||||
|
||||
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(0.1f, after.Condition, precision: 4);
|
||||
Assert.Equal("висит лохмотьями", after.ConditionLabel);
|
||||
}
|
||||
|
||||
private static async Task<WornItem> FirstGirlTopAsync(HttpClient client, int schoolId)
|
||||
{
|
||||
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||
@@ -129,6 +151,26 @@ public class InventoryApiTests(AppHostFixture fixture)
|
||||
root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static void SetFirstWornCondition(string path, float condition)
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(path))
|
||||
?? throw new InvalidOperationException("People file parsed to nothing.");
|
||||
foreach (var person in root["people"]?.AsArray() ?? [])
|
||||
{
|
||||
foreach (var item in person?["items"]?.AsArray() ?? [])
|
||||
{
|
||||
if (item?["location"]?.GetValue<string>() == "worn")
|
||||
{
|
||||
item["condition"] = condition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
path,
|
||||
root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private sealed record SavesDirectoryResponse(string Path);
|
||||
|
||||
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
||||
@@ -146,7 +188,9 @@ public class InventoryApiTests(AppHostFixture fixture)
|
||||
string Label,
|
||||
string? Color,
|
||||
string? ColorLabel,
|
||||
IReadOnlyList<Layer> Layers);
|
||||
IReadOnlyList<Layer> Layers,
|
||||
float Condition,
|
||||
string ConditionLabel);
|
||||
|
||||
private sealed record Layer(string DefName, string Label);
|
||||
|
||||
|
||||
@@ -63,4 +63,20 @@ public class BehaviorDefTests
|
||||
|
||||
Assert.Contains("carry mass", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeApparelWear_FailsTheCatalog()
|
||||
{
|
||||
var error = Assert.Throws<ContentLoadException>(() => _loader.Load(
|
||||
[CatalogLoader.CorePackId],
|
||||
[
|
||||
PackDocuments.Def(
|
||||
CatalogLoader.CorePackId,
|
||||
"behavior",
|
||||
"rules",
|
||||
"""{ "defName": "Behavior", "apparelWearPerHour": -0.1 }"""),
|
||||
]));
|
||||
|
||||
Assert.Contains("apparelWearPerHour", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ public class VanillaCoreTests
|
||||
Assert.Equal(6f, catalog.BehaviorRules.LunchWeight);
|
||||
Assert.Equal(5f, catalog.BehaviorRules.CarryMassBase);
|
||||
Assert.Equal(0.4f, catalog.BehaviorRules.OptionalApparelChance);
|
||||
Assert.Equal(0.01f, catalog.BehaviorRules.ApparelWearPerHour);
|
||||
Assert.Equal(0.15f, catalog.BehaviorRules.ApparelReplaceBelow);
|
||||
Assert.Equal(4, catalog.BehaviorRules.ApparelConditionBands.Count);
|
||||
Assert.Equal("ApparelConditionIntact", catalog.BehaviorRules.ApparelConditionBands[0].Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -147,6 +151,12 @@ public class VanillaCoreTests
|
||||
keys.Add(BodyBuilds.Attribute);
|
||||
keys.AddRange(BodyBuilds.Values);
|
||||
keys.AddRange(ApparelLayers.All);
|
||||
if (catalog.BehaviorRules is { } rules)
|
||||
{
|
||||
keys.AddRange(rules.ApparelConditionBands.Select(band => band.Id));
|
||||
}
|
||||
|
||||
keys.Add("ApparelReplaced");
|
||||
|
||||
foreach (var value in catalog.BodyAttributes.Values.SelectMany(def => def.Options))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Schedule;
|
||||
|
||||
namespace HSchool.Simulation.Tests;
|
||||
|
||||
public class ApparelWearTests
|
||||
{
|
||||
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime TuesdayLesson = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime TuesdayEvening = new(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime WednesdayMorning = new(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void HourOnCampus_DropsWornShirtByWearPerHour()
|
||||
{
|
||||
using var school = OpenStaffed();
|
||||
AdvanceTo(school, TuesdayLesson);
|
||||
var (person, index) = WornShirt(school, onCampus: true);
|
||||
var rate = school.Catalog!.BehaviorRules!.ApparelWearPerHour;
|
||||
var before = person.Items[index].Condition;
|
||||
|
||||
TickMinutes(school, 60);
|
||||
|
||||
var row = school.CapturePresence().Single(item => item.PersonId == person.Id);
|
||||
Assert.NotNull(row.NodeId);
|
||||
Assert.Equal(before - rate, person.Items[index].Condition, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HourOnCampus_LeavesBagAndLockerShirtsAlone()
|
||||
{
|
||||
using var school = OpenStaffed();
|
||||
AdvanceTo(school, TuesdayLesson);
|
||||
var (person, wornIndex) = WornShirt(school, onCampus: true);
|
||||
var bag = person.Items[wornIndex] with { Location = ItemLocations.Bag, Condition = 1f };
|
||||
var locker = person.Items[wornIndex] with { Location = ItemLocations.Locker, Condition = 1f };
|
||||
AddItem(person, bag);
|
||||
AddItem(person, locker);
|
||||
|
||||
TickMinutes(school, 60);
|
||||
|
||||
Assert.True(person.Items[wornIndex].Condition < 1f);
|
||||
Assert.Equal(1f, person.Items.Single(item => item.Location == ItemLocations.Bag && item.Def == bag.Def).Condition);
|
||||
Assert.Equal(1f, person.Items.Single(item => item.Location == ItemLocations.Locker && item.Def == locker.Def).Condition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WearWithoutCrossingABand_DoesNotMarkPeopleChanged()
|
||||
{
|
||||
using var school = OpenStaffed();
|
||||
AdvanceTo(school, TuesdayLesson);
|
||||
var changed = false;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
changed |= school.Tick(0.2d, 5d);
|
||||
}
|
||||
|
||||
Assert.False(changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossingAConditionBand_MarksPeopleChanged()
|
||||
{
|
||||
using var school = OpenStaffed();
|
||||
AdvanceTo(school, TuesdayLesson);
|
||||
var (person, index) = WornShirt(school, onCampus: true);
|
||||
SetItem(person, index, person.Items[index] with { Condition = 0.755f });
|
||||
|
||||
var changed = false;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
changed |= school.Tick(0.2d, 5d);
|
||||
}
|
||||
|
||||
Assert.True(person.Items[index].Condition < 0.75f);
|
||||
Assert.True(changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RaggedWornShirt_IsReplacedByWorkMorning()
|
||||
{
|
||||
using var school = OpenEmpty(TuesdayEvening);
|
||||
var (person, index) = WornShirt(school);
|
||||
var def = person.Items[index].Def;
|
||||
var color = person.Items[index].Color;
|
||||
SetItem(person, index, person.Items[index] with { Condition = 0.05f });
|
||||
|
||||
var result = school.TrySkipEmpty();
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.True(result.PeopleChanged);
|
||||
Assert.Equal(WednesdayMorning, school.Clock.Time);
|
||||
Assert.Equal(def, person.Items[index].Def);
|
||||
Assert.Equal(color, person.Items[index].Color);
|
||||
Assert.Equal(1f, person.Items[index].Condition);
|
||||
Assert.Contains(
|
||||
school.DayLog,
|
||||
row => row.PersonId == person.Id
|
||||
&& row.Type == PersonLogTypes.ApparelReplaced
|
||||
&& row.ThingDef == def);
|
||||
var caption = school.DayLog.First(row => row.PersonId == person.Id).Caption(school.Catalog!, "ru");
|
||||
Assert.StartsWith("получил новую ", caption, StringComparison.Ordinal);
|
||||
Assert.Contains(school.Catalog!.Label("ru", school.Catalog.Things[def]), caption, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipThroughTheNight_MatchesALivedNight()
|
||||
{
|
||||
using var skipped = OpenEmpty(TuesdayEvening);
|
||||
using var lived = OpenEmpty(TuesdayEvening);
|
||||
var skippedShirt = WornShirt(skipped);
|
||||
var livedShirt = WornShirt(lived);
|
||||
SetItem(skippedShirt.Person, skippedShirt.Index, skippedShirt.Person.Items[skippedShirt.Index] with { Condition = 0.05f });
|
||||
SetItem(livedShirt.Person, livedShirt.Index, livedShirt.Person.Items[livedShirt.Index] with { Condition = 0.05f });
|
||||
|
||||
Assert.True(skipped.TrySkipEmpty().Succeeded);
|
||||
while (lived.Clock.Time < WednesdayMorning)
|
||||
{
|
||||
lived.Tick(0.2d, 5d);
|
||||
}
|
||||
|
||||
Assert.Equal(skipped.Clock.Time, lived.Clock.Time);
|
||||
Assert.Equal(1f, skippedShirt.Person.Items[skippedShirt.Index].Condition);
|
||||
Assert.Equal(1f, livedShirt.Person.Items[livedShirt.Index].Condition);
|
||||
Assert.Equal(skippedShirt.Person.Items[skippedShirt.Index].Def, livedShirt.Person.Items[livedShirt.Index].Def);
|
||||
Assert.Equal(
|
||||
skipped.DayLog.Select(row => (row.Type, row.ThingDef)).OrderBy(row => row.ThingDef, StringComparer.Ordinal),
|
||||
lived.DayLog.Select(row => (row.Type, row.ThingDef)).OrderBy(row => row.ThingDef, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionCaption_FollowsTheCatalogBands()
|
||||
{
|
||||
var catalog = Vanilla().Catalog;
|
||||
Assert.Equal("ApparelConditionIntact", ApparelCondition.BandId(catalog.BehaviorRules, 1f));
|
||||
Assert.Equal("ApparelConditionWorn", ApparelCondition.BandId(catalog.BehaviorRules, 0.5f));
|
||||
Assert.Equal("ApparelConditionTorn", ApparelCondition.BandId(catalog.BehaviorRules, 0.2f));
|
||||
Assert.Equal("ApparelConditionRags", ApparelCondition.BandId(catalog.BehaviorRules, 0.1f));
|
||||
Assert.Equal("целая", ApparelCondition.Label(catalog, "ru", 1f));
|
||||
Assert.Equal("висит лохмотьями", ApparelCondition.Label(catalog, "ru", 0.1f));
|
||||
Assert.Equal("intact", ApparelCondition.Label(catalog, "en", 1f));
|
||||
}
|
||||
|
||||
private static (Person Person, int Index) WornShirt(School school, bool onCampus = false)
|
||||
{
|
||||
var here = onCampus
|
||||
? school.CapturePresence()
|
||||
.Where(row => row.NodeId is not null)
|
||||
.Select(row => row.PersonId)
|
||||
.ToHashSet(StringComparer.Ordinal)
|
||||
: null;
|
||||
foreach (var person in school.Roster!.People)
|
||||
{
|
||||
if (here is not null && !here.Contains(person.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < person.Items.Count; i++)
|
||||
{
|
||||
if (person.Items[i].Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)
|
||||
&& (person.Items[i].Def.Equals("Shirt", StringComparison.Ordinal)
|
||||
|| person.Items[i].Def.Equals("TShirt", StringComparison.Ordinal)))
|
||||
{
|
||||
return (person, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No worn Shirt in the roster.");
|
||||
}
|
||||
|
||||
private static void SetItem(Person person, int index, InventoryItem item)
|
||||
{
|
||||
if (person.Items is not IList<InventoryItem> list || list.IsReadOnly)
|
||||
{
|
||||
throw new InvalidOperationException("Items is not a mutable list.");
|
||||
}
|
||||
|
||||
list[index] = item;
|
||||
}
|
||||
|
||||
private static void AddItem(Person person, InventoryItem item)
|
||||
{
|
||||
if (person.Items is not IList<InventoryItem> list || list.IsReadOnly)
|
||||
{
|
||||
throw new InvalidOperationException("Items is not a mutable list.");
|
||||
}
|
||||
|
||||
list.Add(item);
|
||||
}
|
||||
|
||||
private static void TickMinutes(School school, int minutes)
|
||||
{
|
||||
for (var i = 0; i < minutes; i++)
|
||||
{
|
||||
school.Tick(0.2d, 5d);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AdvanceTo(School school, DateTime until)
|
||||
{
|
||||
while (school.Clock.Time < until)
|
||||
{
|
||||
school.Tick(0.2d, 5d);
|
||||
}
|
||||
}
|
||||
|
||||
private static School OpenStaffed()
|
||||
{
|
||||
var (catalog, map) = Vanilla();
|
||||
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning);
|
||||
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning);
|
||||
var schoolClass = roster.Classes.First(row =>
|
||||
row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
|
||||
var school = School.Create(1, "Износ", TuesdayMorning, catalog, map);
|
||||
school.InstallPeople(roster, seed: 1, "Russia", pool);
|
||||
school.SetTimetable(new Timetable(
|
||||
[
|
||||
new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1),
|
||||
new LessonPlacement(schoolClass.Id, "PhysicalEducation", "t2", "gym-hall", Day: 1, Period: 2),
|
||||
],
|
||||
[]));
|
||||
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
|
||||
return school;
|
||||
}
|
||||
|
||||
private static School OpenEmpty(DateTime start)
|
||||
{
|
||||
var (catalog, map) = Vanilla();
|
||||
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
|
||||
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
|
||||
var school = School.Create(1, "Износ ночь", start, catalog, map);
|
||||
school.InstallPeople(roster, seed: 1, "Russia", pool);
|
||||
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
|
||||
return school;
|
||||
}
|
||||
|
||||
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||
var documents = new List<ContentDocument>();
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
|
||||
}
|
||||
|
||||
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
|
||||
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
|
||||
Assert.NotNull(map);
|
||||
return (catalog, map);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user