diff --git a/docs/phases/34-wear-home.md b/docs/phases/34-wear-home.md index 6a7cb89..13975f0 100644 --- a/docs/phases/34-wear-home.md +++ b/docs/phases/34-wear-home.md @@ -11,26 +11,26 @@ ## Задачи -- [ ] Прочность экземпляра 0–1; падает только у надетого, только пока человек в школе, от +- [x] Прочность экземпляра 0–1; падает только у надетого, только пока человек в школе, от игрового времени. Формула скорости — данные -- [ ] Пороги подписей в дефе (`целая` / `поношенная` / `порванная` / `висит лохмотьями`); +- [x] Пороги подписей в дефе (`целая` / `поношенная` / `порванная` / `висит лохмотьями`); карточка — полоска + подпись, клиент не считает пороги сам -- [ ] Порог «не выходить»: утром вне школы ветхая надетая вещь заменяется свежей того же def +- [x] Порог «не выходить»: утром вне школы ветхая надетая вещь заменяется свежей того же def и цвета (или ближайшего уместного). Событие для истории фазы 36: тип и подпись готовы даже если лог ещё не рисуется -- [ ] Пропуск пустого времени применяет утреннюю замену, иначе после «пропустить» школа в +- [x] Пропуск пустого времени применяет утреннюю замену, иначе после «пропустить» школа в лохмотьях -- [ ] Сумка и шкафчик не изнашиваются -- [ ] `people.json` пишется после замены, не каждый тик износа: либо порог пересечён, либо +- [x] Сумка и шкафчик не изнашиваются +- [x] `people.json` пишется после замены, не каждый тик износа: либо порог пересечён, либо утренняя выдача ## Тесты, без которых фаза не закрыта -- [ ] День в школе снижает прочность надетой рубашки на заявленную величину, ±эпсилон -- [ ] Рубашка в сумке за тот же день не меняется -- [ ] Человек с одеждой ниже порога к утру рабочего дня одет в свежую; def совпадает -- [ ] Skip через ночь даёт ту же замену, что прожитая ночь -- [ ] Подпись на карточке меняется вместе с порогом +- [x] День в школе снижает прочность надетой рубашки на заявленную величину, ±эпсилон +- [x] Рубашка в сумке за тот же день не меняется +- [x] Человек с одеждой ниже порога к утру рабочего дня одет в свежую; def совпадает +- [x] Skip через ночь даёт ту же замену, что прожитая ночь +- [x] Подпись на карточке меняется вместе с порогом ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 27e1eb3..41c2660 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -146,7 +146,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [34. Износ и дом](34-wear-home.md) | 🔄 | Прочность, утренняя замена, из ветхого не выходят | +| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят | | [35. Уместность](35-appropriateness.md) | ⬜ | Правила, переодевание действием, физкультура | | [36. Вкладки карточки](36-person-card-tabs.md) | 🔄 | Одежда, ноша, сейчас + история за сегодня | | [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра | diff --git a/docs/protocol.md b/docs/protocol.md index b3bf73c..81f918e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -255,10 +255,12 @@ is that def in the request locale. HTTP JSON is additive — no protocol version `skills` lists only keys the person has, not every `SkillDef` in the catalog. A first-year has no Chemistry; a related tongue from the name set may sit beside the native at a low value. -`worn` is what is on the body right now: def, colour, and the layers it occupies. `carried` is -the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms; overload -does not slow walking. Home and locker stay in `people.json` and are not on this card. The -people list does not include any of these fields. +`worn` is what is on the body right now: def, colour, the layers it occupies, `condition` +(0–1) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` / +`висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds. +`carried` is the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms; +overload does not slow walking. Home and locker stay in `people.json` and are not on this card. +The people list does not include any of these fields. ```json { @@ -289,7 +291,9 @@ people list does not include any of these fields. "label": "Рубашка", "color": "White", "colorLabel": "Белый", - "layers": [{ "defName": "Top", "label": "Верх" }] + "layers": [{ "defName": "Top", "label": "Верх" }], + "condition": 1, + "conditionLabel": "целая" } ], "carried": [ diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 9da73ac..a6e7376 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -303,6 +303,8 @@ export interface WornItem { readonly color: string | null; readonly colorLabel: string | null; readonly layers: readonly DefLabel[]; + readonly condition: number; + readonly conditionLabel: string; } export interface CarriedItem { diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index f37f00c..b2d5e75 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -676,6 +676,34 @@ body { font-variant-numeric: tabular-nums; } +.people__garments { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px 16px; +} + +.people__garment { + display: flex; + flex-direction: column; + gap: 2px; +} + +.people__wear { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 8px; + font-size: 12px; +} + +.people__wear-label { + grid-column: 1 / -1; + color: var(--text-muted); +} + +.people__wear .people__need-track { + grid-column: 1 / -1; +} + .people__tags { display: flex; flex-wrap: wrap; diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index bcb2647..f63ecae 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -41,6 +41,8 @@ function card(overrides: Partial = {}): PersonCard { color: 'White', colorLabel: 'Белый', layers: [{ defName: 'Top', label: 'Верх' }], + condition: 1, + conditionLabel: 'целая', }, ], carried: [ @@ -74,4 +76,58 @@ describe('renderPersonCard', () => { expect(root.textContent).toContain('Учебник · Математика'); expect(root.querySelector('.people__tabs')).toBeNull(); }); + + it('shows the server condition caption, not a band inferred from the number', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard( + root, + card({ + worn: [ + { + defName: 'Shirt', + label: 'Рубашка', + color: 'White', + colorLabel: 'Белый', + layers: [{ defName: 'Top', label: 'Верх' }], + condition: 0.1, + conditionLabel: 'целая', + }, + ], + }), + () => {}, + ); + + expect(root.textContent).toContain('целая'); + expect(root.textContent).not.toContain('висит лохмотьями'); + expect(root.querySelector('.people__wear-label')?.textContent).toBe('целая'); + expect((root.querySelector('.people__need-fill') as HTMLElement | null)?.style.width).toBe('10%'); + }); + + it('changes the caption when the payload label crosses a threshold', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card(), () => {}); + expect(root.querySelector('.people__wear-label')?.textContent).toBe('целая'); + + root.replaceChildren(); + renderPersonCard( + root, + card({ + worn: [ + { + defName: 'Shirt', + label: 'Рубашка', + color: 'White', + colorLabel: 'Белый', + layers: [{ defName: 'Top', label: 'Верх' }], + condition: 0.1, + conditionLabel: 'висит лохмотьями', + }, + ], + }), + () => {}, + ); + expect(root.querySelector('.people__wear-label')?.textContent).toBe('висит лохмотьями'); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index b970160..dcb4c2b 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -119,22 +119,36 @@ function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void { return; } - const grid = el('dl', { class: 'people__pairs' }); + const list = el('div', { class: 'people__garments' }); for (const row of rows) { const layers = row.layers.map((layer) => layer.label).join(', '); const color = row.colorLabel ?? row.color; const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label; - grid.append( + const share = Math.max(0, Math.min(1, row.condition)); + const fill = el('span', { class: 'people__need-fill' }); + fill.style.width = `${Math.round(share * 100)}%`; + fill.classList.toggle('people__need-fill--low', share < 0.25); + list.append( el( 'div', - { class: 'people__pair' }, - el('dt', { text: layers.length > 0 ? layers : row.label }), - el('dd', { text: value }), + { class: 'people__garment' }, + el( + 'div', + { class: 'people__pair' }, + el('dt', { text: layers.length > 0 ? layers : row.label }), + el('dd', { text: value }), + ), + el( + 'div', + { class: 'people__wear' }, + el('span', { class: 'people__wear-label', text: row.conditionLabel }), + el('span', { class: 'people__need-track' }, fill), + ), ), ); } - parent.append(section(t('peopleApparel')), grid); + parent.append(section(t('peopleApparel')), list); } function appendCarry(parent: HTMLElement, card: PersonCard): void { diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 325fc4f..d3e1492 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -531,6 +531,18 @@ internal static class PeopleDefValidator throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' optionalApparelChance must be 0–1."); } + if (behavior.ApparelWearPerHour < 0f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparelWearPerHour cannot be negative."); + } + + if (behavior.ApparelReplaceBelow is < 0f or > 1f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparelReplaceBelow must be 0–1."); + } + + ValidateConditionBands(behavior); + // A pack may invert this on purpose — lunch then pulls the class out of the lesson. // The warning is the catch; refusing to load would make the number unmoddable. if (behavior.LunchWeight > behavior.DutyLessonWeight) @@ -540,6 +552,47 @@ internal static class PeopleDefValidator } } + private static void ValidateConditionBands(BehaviorDef behavior) + { + var bands = behavior.ApparelConditionBands; + if (bands.Count == 0) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparelConditionBands cannot be empty."); + } + + var ids = new HashSet(StringComparer.Ordinal); + var sawZero = false; + foreach (var band in bands) + { + if (string.IsNullOrWhiteSpace(band.Id)) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apparel condition band is missing an id."); + } + + if (band.Min is < 0f or > 1f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' apparel condition '{band.Id}' min must be 0–1."); + } + + if (!ids.Add(band.Id)) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' repeats apparel condition '{band.Id}'."); + } + + if (band.Min == 0f) + { + sawZero = true; + } + } + + if (!sawZero) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' apparelConditionBands must include a min of 0 so rags have a caption."); + } + } + private static void ValidateHoliday(HolidayDef holiday) { if (holiday.Abstract) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 500cb85..624e3b8 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -268,6 +268,40 @@ public sealed class BehaviorDef : Def /// Chance each optional layer (sweater, coat, hat, accessory) is worn at generation. public float OptionalApparelChance { get; init; } = 0.4f; + + /// + /// Condition lost per game hour while the thing is worn on campus. Bag, locker and home do + /// not wear. The number lives here so a pack can make clothes last a term or a week. + /// + public float ApparelWearPerHour { get; init; } = 0.01f; + + /// + /// Worn apparel below this stays home: morning replacement issues a fresh instance. + /// At the threshold they still go out, even when the caption already says torn. + /// + public float ApparelReplaceBelow { get; init; } = 0.15f; + + /// + /// Caption bands for a 0–1 condition bar. Highest + /// the value still meets wins. Empty falls back to . + /// + public IReadOnlyList ApparelConditionBands { get; init; } = DefaultConditionBands; + + public static IReadOnlyList DefaultConditionBands { get; } = + [ + new() { Min = 0.75f, Id = "ApparelConditionIntact" }, + new() { Min = 0.4f, Id = "ApparelConditionWorn" }, + new() { Min = 0.15f, Id = "ApparelConditionTorn" }, + new() { Min = 0f, Id = "ApparelConditionRags" }, + ]; +} + +/// One caption on the condition bar. is a locale key, not a Def. +public sealed class ApparelConditionBand +{ + public float Min { get; init; } + + public required string Id { get; init; } } public enum BodyAttributeKind diff --git a/src/HSchool.People/Inventory.cs b/src/HSchool.People/Inventory.cs index c4e957d..0aebed3 100644 --- a/src/HSchool.People/Inventory.cs +++ b/src/HSchool.People/Inventory.cs @@ -11,7 +11,7 @@ public static class ItemLocations /// /// One thing on a person. is set only on a textbook instance — the def is -/// shared. Condition is 1 at birth; wear is a later phase. +/// shared. Condition is 0–1; it falls while worn on campus and resets on the morning issue. /// public sealed record InventoryItem( string Def, diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 2b97582..dd378a5 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -72,7 +72,9 @@ internal sealed record WornItemResponse( string Label, string? Color, string? ColorLabel, - IReadOnlyList Layers); + IReadOnlyList Layers, + float Condition, + string ConditionLabel); internal sealed record CarriedItemResponse( string DefName, diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index fe5d759..e777e94 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -288,7 +288,11 @@ internal static class PersonCardReader ThingLabel(catalog, locale, item.Def), item.Color, ColorLabel(catalog, locale, item.Color), - layers)); + layers, + item.Condition, + catalog is null + ? ApparelCondition.BandId(null, item.Condition) + : ApparelCondition.Label(catalog, locale, item.Condition))); } return rows; diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 7bd9506..8500666 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -28,4 +28,14 @@ "carryMassPerHauling": 0.08, // Sweater, coat, hat, accessory — each rolled separately at generation. "optionalApparelChance": 0.4, + // Worn on campus only. 0.01 per hour → a school week (~40 h) drops a fifth of the bar. + "apparelWearPerHour": 0.01, + // Below this they do not leave home; morning issues a fresh instance of the same def. + "apparelReplaceBelow": 0.15, + "apparelConditionBands": [ + { "min": 0.75, "id": "ApparelConditionIntact" }, + { "min": 0.4, "id": "ApparelConditionWorn" }, + { "min": 0.15, "id": "ApparelConditionTorn" }, + { "min": 0, "id": "ApparelConditionRags" }, + ], } diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 43229d8..07fe741 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -162,5 +162,10 @@ "WinterBreak": "Winter break", "SpringBreak": "Spring break", "SummerBreak": "Summer break", + "ApparelConditionIntact": "intact", + "ApparelConditionWorn": "worn", + "ApparelConditionTorn": "torn", + "ApparelConditionRags": "in rags", + "ApparelReplaced": "got a new {0}", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 8041edc..4bc62c4 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -162,5 +162,10 @@ "WinterBreak": "Зимние каникулы", "SpringBreak": "Весенние каникулы", "SummerBreak": "Летние каникулы", + "ApparelConditionIntact": "целая", + "ApparelConditionWorn": "поношенная", + "ApparelConditionTorn": "порванная", + "ApparelConditionRags": "висит лохмотьями", + "ApparelReplaced": "получил новую {0}", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/ApparelWear.cs b/src/HSchool.Simulation/ApparelWear.cs new file mode 100644 index 0000000..cdad112 --- /dev/null +++ b/src/HSchool.Simulation/ApparelWear.cs @@ -0,0 +1,259 @@ +using System.Globalization; +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// Log row types phase 36 will page. Captions are ready even while the card has no tab. +public static class PersonLogTypes +{ + public const string ApparelReplaced = "apparel-replaced"; +} + +/// +/// One thing that happened to a person today. Stored on the worker, not in people.json. +/// The day boundary is six in the morning — the same hour skip lands on. +/// +public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, string? ThingDef) +{ + public string Caption(DefCatalog catalog, string locale) + { + ArgumentNullException.ThrowIfNull(catalog); + if (!Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal) || ThingDef is null) + { + return Type; + } + + var name = catalog.Things.TryGetValue(ThingDef, out var def) + ? catalog.Label(locale, def) + : ThingDef; + return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name); + } +} + +/// Condition captions live in the catalog. The client draws what it is told. +public static class ApparelCondition +{ + public static string BandId(BehaviorDef? rules, float condition) + { + var bands = rules?.ApparelConditionBands is { Count: > 0 } listed + ? listed + : BehaviorDef.DefaultConditionBands; + ApparelConditionBand? best = null; + foreach (var band in bands) + { + if (condition >= band.Min && (best is null || band.Min > best.Min)) + { + best = band; + } + } + + return best?.Id ?? "ApparelConditionRags"; + } + + public static string Label(DefCatalog catalog, string locale, float condition) => + catalog.Text(locale, BandId(catalog.BehaviorRules, condition)); +} + +/// +/// Worn apparel loses condition on campus from game time, then morning issues a fresh copy +/// of anything too ragged to leave home in. Bag and locker are left alone. +/// +internal static class ApparelWear +{ + private static readonly QueryDescription Identities = + new QueryDescription().WithAll(); + + public static bool Apply(School school, double gameMinutes, DateTime before) + { + var bandCrossed = Wear(school, gameMinutes); + if (!CrossedDayStart(before, school.Clock.Time)) + { + return bandCrossed; + } + + school.ResetDayLog(); + if (school.Catalog is null || !SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays)) + { + return bandCrossed; + } + + return bandCrossed | ReplaceRags(school); + } + + internal static bool CrossedDayStart(DateTime before, DateTime after) + { + if (after <= before) + { + return false; + } + + var cursor = DateTime.SpecifyKind(before.Date, DateTimeKind.Utc).Add(SchoolDay.DayStart.ToTimeSpan()); + if (before >= cursor) + { + cursor = cursor.AddDays(1); + } + + return after >= cursor; + } + + private static bool Wear(School school, double gameMinutes) + { + if (gameMinutes <= 0 || school.Roster is null || school.Catalog?.BehaviorRules is null) + { + return false; + } + + var rate = school.Catalog.BehaviorRules.ApparelWearPerHour; + if (rate <= 0) + { + return false; + } + + var onCampus = OnCampusIds(school); + if (onCampus.Count == 0) + { + return false; + } + + var drop = (float)(rate * (gameMinutes / 60d)); + var crossed = false; + foreach (var person in school.Roster.People) + { + if (!onCampus.Contains(person.Id)) + { + continue; + } + + crossed |= WearPerson(school.Catalog, person, drop); + } + + return crossed; + } + + private static bool WearPerson(DefCatalog catalog, Person person, float drop) + { + var crossed = false; + for (var i = 0; i < person.Items.Count; i++) + { + var item = person.Items[i]; + if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal) + || !IsApparel(catalog, item.Def)) + { + continue; + } + + var next = Math.Clamp(item.Condition - drop, 0f, 1f); + if (next == item.Condition) + { + continue; + } + + if (ApparelCondition.BandId(catalog.BehaviorRules, item.Condition) + != ApparelCondition.BandId(catalog.BehaviorRules, next)) + { + crossed = true; + } + + SetItem(person, i, item with { Condition = next }); + } + + return crossed; + } + + private static bool ReplaceRags(School school) + { + if (school.Roster is null || school.Catalog?.BehaviorRules is null) + { + return false; + } + + var onCampus = OnCampusIds(school); + var threshold = school.Catalog.BehaviorRules.ApparelReplaceBelow; + var replaced = false; + foreach (var person in school.Roster.People) + { + if (onCampus.Contains(person.Id)) + { + continue; + } + + replaced |= ReplacePerson(school, person, threshold); + } + + return replaced; + } + + private static bool ReplacePerson(School school, Person person, float threshold) + { + var catalog = school.Catalog!; + var replaced = false; + for (var i = 0; i < person.Items.Count; i++) + { + var item = person.Items[i]; + if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal) + || item.Condition >= threshold + || !IsApparel(catalog, item.Def)) + { + continue; + } + + var color = NearestColor(catalog, item.Def, item.Color); + SetItem(person, i, item with { Condition = 1f, Color = color }); + school.AppendDayLog(new PersonLogEvent( + person.Id, + school.Clock.Time, + PersonLogTypes.ApparelReplaced, + item.Def)); + replaced = true; + } + + return replaced; + } + + private static HashSet OnCampusIds(School school) + { + var ids = new HashSet(StringComparer.Ordinal); + var world = school.World; + world.Query(in Identities, (ref PersonIdentity identity, ref Presence presence) => + { + if (presence.IsOnCampus) + { + ids.Add(identity.Id); + } + }); + return ids; + } + + private static bool IsApparel(DefCatalog catalog, string defName) => + catalog.Things.TryGetValue(defName, out var def) && def.Layers.Count > 0; + + private static string? NearestColor(DefCatalog catalog, string defName, string? current) + { + if (!catalog.Things.TryGetValue(defName, out var def) || def.Colors.Count == 0) + { + return current; + } + + if (current is not null + && def.Colors.Contains(current, StringComparer.Ordinal)) + { + return current; + } + + return def.Colors[0]; + } + + private static void SetItem(Person person, int index, InventoryItem item) + { + if (person.Items is IList list && !list.IsReadOnly) + { + list[index] = item; + return; + } + + throw new InvalidOperationException($"Cannot mutate items for {person.Id}."); + } +} diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 161e622..81dcf13 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -16,6 +16,7 @@ public sealed class School : IDisposable public const int MaxNameLength = 40; private bool _disposed; + private readonly List _dayLog = []; internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map) { @@ -114,6 +115,15 @@ public sealed class School : IDisposable public int PendingDecisionCount => DecisionQueue.Count; + /// + /// Today's history for phase 36. Cleared at six in the morning. Not written to disk. + /// + public IReadOnlyList DayLog => _dayLog; + + internal void ResetDayLog() => _dayLog.Clear(); + + internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); + public void QueueDecision(string personId) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -212,6 +222,7 @@ public sealed class School : IDisposable PlanDay = null; LastDecisionSlot = null; NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes); + peopleChanged |= ApparelWear.Apply(this, gameMinutes: 0, before); SyncWeather(force: true); return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged); } @@ -251,8 +262,8 @@ public sealed class School : IDisposable } } - /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, then need decay. - /// when the roster or the applicant pool changed this step. + /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, need decay, then apparel wear. + /// when the roster, applicant pool or wardrobe changed this step. public bool Tick(double deltaTime, double gameMinutesPerRealSecond) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -285,6 +296,7 @@ public sealed class School : IDisposable PresenceSystem.EnqueueNewlyUrgent(this, below); PresenceSystem.DrainDecisions(this); LessonLearningSystem.Apply(this, gameMinutes); + peopleChanged |= ApparelWear.Apply(this, gameMinutes, before); } } else diff --git a/tests/HSchool.AppHost.Tests/InventoryApiTests.cs b/tests/HSchool.AppHost.Tests/InventoryApiTests.cs index a7a308a..0a74f8f 100644 --- a/tests/HSchool.AppHost.Tests/InventoryApiTests.cs +++ b/tests/HSchool.AppHost.Tests/InventoryApiTests.cs @@ -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 FirstGirlTopAsync(HttpClient client, int schoolId) { var page = await client.GetFromJsonAsync( @@ -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() == "worn") + { + item["condition"] = condition; + } + } + } + + File.WriteAllText( + path, + root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + private sealed record SavesDirectoryResponse(string Path); private sealed record PeoplePage(IReadOnlyList People); @@ -146,7 +188,9 @@ public class InventoryApiTests(AppHostFixture fixture) string Label, string? Color, string? ColorLabel, - IReadOnlyList Layers); + IReadOnlyList Layers, + float Condition, + string ConditionLabel); private sealed record Layer(string DefName, string Label); diff --git a/tests/HSchool.Content.Tests/BehaviorDefTests.cs b/tests/HSchool.Content.Tests/BehaviorDefTests.cs index b96c224..0d962f5 100644 --- a/tests/HSchool.Content.Tests/BehaviorDefTests.cs +++ b/tests/HSchool.Content.Tests/BehaviorDefTests.cs @@ -63,4 +63,20 @@ public class BehaviorDefTests Assert.Contains("carry mass", error.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void NegativeApparelWear_FailsTheCatalog() + { + var error = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "apparelWearPerHour": -0.1 }"""), + ])); + + Assert.Contains("apparelWearPerHour", error.Message, StringComparison.Ordinal); + } } diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 682d66b..542fcfb 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -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); } /// @@ -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)) { diff --git a/tests/HSchool.Simulation.Tests/ApparelWearTests.cs b/tests/HSchool.Simulation.Tests/ApparelWearTests.cs new file mode 100644 index 0000000..48ad82e --- /dev/null +++ b/tests/HSchool.Simulation.Tests/ApparelWearTests.cs @@ -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 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 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(); + 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); + } +}