Wear worn clothes on campus and replace rags at the work morning.
Condition drops from catalog hours only while the person is at school; skip through the night issues the same fresh set as a lived night so the school does not walk in in rags. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+11
-11
@@ -11,26 +11,26 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Прочность экземпляра 0–1; падает только у надетого, только пока человек в школе, от
|
- [x] Прочность экземпляра 0–1; падает только у надетого, только пока человек в школе, от
|
||||||
игрового времени. Формула скорости — данные
|
игрового времени. Формула скорости — данные
|
||||||
- [ ] Пороги подписей в дефе (`целая` / `поношенная` / `порванная` / `висит лохмотьями`);
|
- [x] Пороги подписей в дефе (`целая` / `поношенная` / `порванная` / `висит лохмотьями`);
|
||||||
карточка — полоска + подпись, клиент не считает пороги сам
|
карточка — полоска + подпись, клиент не считает пороги сам
|
||||||
- [ ] Порог «не выходить»: утром вне школы ветхая надетая вещь заменяется свежей того же def
|
- [x] Порог «не выходить»: утром вне школы ветхая надетая вещь заменяется свежей того же def
|
||||||
и цвета (или ближайшего уместного). Событие для истории фазы 36: тип и подпись готовы
|
и цвета (или ближайшего уместного). Событие для истории фазы 36: тип и подпись готовы
|
||||||
даже если лог ещё не рисуется
|
даже если лог ещё не рисуется
|
||||||
- [ ] Пропуск пустого времени применяет утреннюю замену, иначе после «пропустить» школа в
|
- [x] Пропуск пустого времени применяет утреннюю замену, иначе после «пропустить» школа в
|
||||||
лохмотьях
|
лохмотьях
|
||||||
- [ ] Сумка и шкафчик не изнашиваются
|
- [x] Сумка и шкафчик не изнашиваются
|
||||||
- [ ] `people.json` пишется после замены, не каждый тик износа: либо порог пересечён, либо
|
- [x] `people.json` пишется после замены, не каждый тик износа: либо порог пересечён, либо
|
||||||
утренняя выдача
|
утренняя выдача
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] День в школе снижает прочность надетой рубашки на заявленную величину, ±эпсилон
|
- [x] День в школе снижает прочность надетой рубашки на заявленную величину, ±эпсилон
|
||||||
- [ ] Рубашка в сумке за тот же день не меняется
|
- [x] Рубашка в сумке за тот же день не меняется
|
||||||
- [ ] Человек с одеждой ниже порога к утру рабочего дня одет в свежую; def совпадает
|
- [x] Человек с одеждой ниже порога к утру рабочего дня одет в свежую; def совпадает
|
||||||
- [ ] Skip через ночь даёт ту же замену, что прожитая ночь
|
- [x] Skip через ночь даёт ту же замену, что прожитая ночь
|
||||||
- [ ] Подпись на карточке меняется вместе с порогом
|
- [x] Подпись на карточке меняется вместе с порогом
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -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
|
`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.
|
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
|
`worn` is what is on the body right now: def, colour, the layers it occupies, `condition`
|
||||||
the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms; overload
|
(0–1) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` /
|
||||||
does not slow walking. Home and locker stay in `people.json` and are not on this card. The
|
`висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds.
|
||||||
people list does not include any of these fields.
|
`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
|
```json
|
||||||
{
|
{
|
||||||
@@ -289,7 +291,9 @@ people list does not include any of these fields.
|
|||||||
"label": "Рубашка",
|
"label": "Рубашка",
|
||||||
"color": "White",
|
"color": "White",
|
||||||
"colorLabel": "Белый",
|
"colorLabel": "Белый",
|
||||||
"layers": [{ "defName": "Top", "label": "Верх" }]
|
"layers": [{ "defName": "Top", "label": "Верх" }],
|
||||||
|
"condition": 1,
|
||||||
|
"conditionLabel": "целая"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"carried": [
|
"carried": [
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ export interface WornItem {
|
|||||||
readonly color: string | null;
|
readonly color: string | null;
|
||||||
readonly colorLabel: string | null;
|
readonly colorLabel: string | null;
|
||||||
readonly layers: readonly DefLabel[];
|
readonly layers: readonly DefLabel[];
|
||||||
|
readonly condition: number;
|
||||||
|
readonly conditionLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CarriedItem {
|
export interface CarriedItem {
|
||||||
|
|||||||
@@ -670,6 +670,34 @@ body {
|
|||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.people__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
|||||||
color: 'White',
|
color: 'White',
|
||||||
colorLabel: 'Белый',
|
colorLabel: 'Белый',
|
||||||
layers: [{ defName: 'Top', label: 'Верх' }],
|
layers: [{ defName: 'Top', label: 'Верх' }],
|
||||||
|
condition: 1,
|
||||||
|
conditionLabel: 'целая',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
carried: [
|
carried: [
|
||||||
@@ -74,4 +76,58 @@ describe('renderPersonCard', () => {
|
|||||||
expect(root.textContent).toContain('Учебник · Математика');
|
expect(root.textContent).toContain('Учебник · Математика');
|
||||||
expect(root.querySelector('.people__tabs')).toBeNull();
|
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('висит лохмотьями');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,22 +119,36 @@ function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grid = el('dl', { class: 'people__pairs' });
|
const list = el('div', { class: 'people__garments' });
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const layers = row.layers.map((layer) => layer.label).join(', ');
|
const layers = row.layers.map((layer) => layer.label).join(', ');
|
||||||
const color = row.colorLabel ?? row.color;
|
const color = row.colorLabel ?? row.color;
|
||||||
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
|
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__garment' },
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'people__pair' },
|
{ class: 'people__pair' },
|
||||||
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
||||||
el('dd', { text: value }),
|
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 {
|
function appendCarry(parent: HTMLElement, card: PersonCard): void {
|
||||||
|
|||||||
@@ -526,6 +526,18 @@ internal static class PeopleDefValidator
|
|||||||
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' optionalApparelChance must be 0–1.");
|
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.
|
// 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.
|
// The warning is the catch; refusing to load would make the number unmoddable.
|
||||||
if (behavior.LunchWeight > behavior.DutyLessonWeight)
|
if (behavior.LunchWeight > behavior.DutyLessonWeight)
|
||||||
@@ -535,6 +547,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<string>(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)
|
private static void ValidateHoliday(HolidayDef holiday)
|
||||||
{
|
{
|
||||||
if (holiday.Abstract)
|
if (holiday.Abstract)
|
||||||
|
|||||||
@@ -262,6 +262,40 @@ public sealed class BehaviorDef : Def
|
|||||||
|
|
||||||
/// <summary>Chance each optional layer (sweater, coat, hat, accessory) is worn at generation.</summary>
|
/// <summary>Chance each optional layer (sweater, coat, hat, accessory) is worn at generation.</summary>
|
||||||
public float OptionalApparelChance { get; init; } = 0.4f;
|
public float OptionalApparelChance { get; init; } = 0.4f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public float ApparelWearPerHour { get; init; } = 0.01f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public float ApparelReplaceBelow { get; init; } = 0.15f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Caption bands for a 0–1 condition bar. Highest <see cref="ApparelConditionBand.Min"/>
|
||||||
|
/// the value still meets wins. Empty falls back to <see cref="DefaultConditionBands"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ApparelConditionBand> ApparelConditionBands { get; init; } = DefaultConditionBands;
|
||||||
|
|
||||||
|
public static IReadOnlyList<ApparelConditionBand> 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" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One caption on the condition bar. <see cref="Id"/> is a locale key, not a Def.</summary>
|
||||||
|
public sealed class ApparelConditionBand
|
||||||
|
{
|
||||||
|
public float Min { get; init; }
|
||||||
|
|
||||||
|
public required string Id { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BodyAttributeKind
|
public enum BodyAttributeKind
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public static class ItemLocations
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One thing on a person. <see cref="Subject"/> is set only on a textbook instance — the def is
|
/// One thing on a person. <see cref="Subject"/> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record InventoryItem(
|
public sealed record InventoryItem(
|
||||||
string Def,
|
string Def,
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ internal sealed record WornItemResponse(
|
|||||||
string Label,
|
string Label,
|
||||||
string? Color,
|
string? Color,
|
||||||
string? ColorLabel,
|
string? ColorLabel,
|
||||||
IReadOnlyList<DefLabelResponse> Layers);
|
IReadOnlyList<DefLabelResponse> Layers,
|
||||||
|
float Condition,
|
||||||
|
string ConditionLabel);
|
||||||
|
|
||||||
internal sealed record CarriedItemResponse(
|
internal sealed record CarriedItemResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
|
|||||||
@@ -288,7 +288,11 @@ internal static class PersonCardReader
|
|||||||
ThingLabel(catalog, locale, item.Def),
|
ThingLabel(catalog, locale, item.Def),
|
||||||
item.Color,
|
item.Color,
|
||||||
ColorLabel(catalog, locale, 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;
|
return rows;
|
||||||
|
|||||||
@@ -28,4 +28,14 @@
|
|||||||
"carryMassPerHauling": 0.08,
|
"carryMassPerHauling": 0.08,
|
||||||
// Sweater, coat, hat, accessory — each rolled separately at generation.
|
// Sweater, coat, hat, accessory — each rolled separately at generation.
|
||||||
"optionalApparelChance": 0.4,
|
"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" },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,5 +159,10 @@
|
|||||||
"WinterBreak": "Winter break",
|
"WinterBreak": "Winter break",
|
||||||
"SpringBreak": "Spring break",
|
"SpringBreak": "Spring break",
|
||||||
"SummerBreak": "Summer break",
|
"SummerBreak": "Summer break",
|
||||||
|
"ApparelConditionIntact": "intact",
|
||||||
|
"ApparelConditionWorn": "worn",
|
||||||
|
"ApparelConditionTorn": "torn",
|
||||||
|
"ApparelConditionRags": "in rags",
|
||||||
|
"ApparelReplaced": "got a new {0}",
|
||||||
"core": "Core",
|
"core": "Core",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,5 +159,10 @@
|
|||||||
"WinterBreak": "Зимние каникулы",
|
"WinterBreak": "Зимние каникулы",
|
||||||
"SpringBreak": "Весенние каникулы",
|
"SpringBreak": "Весенние каникулы",
|
||||||
"SummerBreak": "Летние каникулы",
|
"SummerBreak": "Летние каникулы",
|
||||||
|
"ApparelConditionIntact": "целая",
|
||||||
|
"ApparelConditionWorn": "поношенная",
|
||||||
|
"ApparelConditionTorn": "порванная",
|
||||||
|
"ApparelConditionRags": "висит лохмотьями",
|
||||||
|
"ApparelReplaced": "получил новую {0}",
|
||||||
"core": "Базовая игра",
|
"core": "Базовая игра",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>Log row types phase 36 will page. Captions are ready even while the card has no tab.</summary>
|
||||||
|
public static class PersonLogTypes
|
||||||
|
{
|
||||||
|
public const string ApparelReplaced = "apparel-replaced";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One thing that happened to a person today. Stored on the worker, not in <c>people.json</c>.
|
||||||
|
/// The day boundary is six in the morning — the same hour skip lands on.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Condition captions live in the catalog. The client draws what it is told.</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ApparelWear
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription Identities =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, Presence>();
|
||||||
|
|
||||||
|
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<string> OnCampusIds(School school)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>(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<InventoryItem> list && !list.IsReadOnly)
|
||||||
|
{
|
||||||
|
list[index] = item;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Cannot mutate items for {person.Id}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public sealed class School : IDisposable
|
|||||||
public const int MaxNameLength = 40;
|
public const int MaxNameLength = 40;
|
||||||
|
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
private readonly List<PersonLogEvent> _dayLog = [];
|
||||||
|
|
||||||
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
|
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
|
||||||
{
|
{
|
||||||
@@ -111,6 +112,15 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
public int PendingDecisionCount => DecisionQueue.Count;
|
public int PendingDecisionCount => DecisionQueue.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Today's history for phase 36. Cleared at six in the morning. Not written to disk.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PersonLogEvent> DayLog => _dayLog;
|
||||||
|
|
||||||
|
internal void ResetDayLog() => _dayLog.Clear();
|
||||||
|
|
||||||
|
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
|
||||||
|
|
||||||
public void QueueDecision(string personId)
|
public void QueueDecision(string personId)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
@@ -208,6 +218,7 @@ public sealed class School : IDisposable
|
|||||||
PlanDay = null;
|
PlanDay = null;
|
||||||
LastDecisionSlot = null;
|
LastDecisionSlot = null;
|
||||||
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
|
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
|
||||||
|
peopleChanged |= ApparelWear.Apply(this, gameMinutes: 0, before);
|
||||||
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
|
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +257,8 @@ public sealed class School : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, then need decay.</summary>
|
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, need decay, then apparel wear.</summary>
|
||||||
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
|
/// <returns><see langword="true"/> when the roster, applicant pool or wardrobe changed this step.</returns>
|
||||||
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
@@ -278,6 +289,7 @@ public sealed class School : IDisposable
|
|||||||
PresenceSystem.EnqueueNewlyUrgent(this, below);
|
PresenceSystem.EnqueueNewlyUrgent(this, below);
|
||||||
PresenceSystem.DrainDecisions(this);
|
PresenceSystem.DrainDecisions(this);
|
||||||
LessonLearningSystem.Apply(this, gameMinutes);
|
LessonLearningSystem.Apply(this, gameMinutes);
|
||||||
|
peopleChanged |= ApparelWear.Apply(this, gameMinutes, before);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class InventoryApiTests(AppHostFixture fixture)
|
|||||||
Assert.NotNull(card);
|
Assert.NotNull(card);
|
||||||
Assert.NotEmpty(card.Worn);
|
Assert.NotEmpty(card.Worn);
|
||||||
Assert.Contains(card.Worn, item => item.Layers.Count > 0 && item.ColorLabel is { Length: > 0 });
|
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.CarryCapacity > 0);
|
||||||
Assert.True(card.CarryMass <= card.CarryCapacity);
|
Assert.True(card.CarryMass <= card.CarryCapacity);
|
||||||
Assert.Contains(card.Carried, item => item.Mass > 0);
|
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);
|
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)
|
private static async Task<WornItem> FirstGirlTopAsync(HttpClient client, int schoolId)
|
||||||
{
|
{
|
||||||
var page = await client.GetFromJsonAsync<PeoplePage>(
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
@@ -129,6 +151,26 @@ public class InventoryApiTests(AppHostFixture fixture)
|
|||||||
root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
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 SavesDirectoryResponse(string Path);
|
||||||
|
|
||||||
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
||||||
@@ -146,7 +188,9 @@ public class InventoryApiTests(AppHostFixture fixture)
|
|||||||
string Label,
|
string Label,
|
||||||
string? Color,
|
string? Color,
|
||||||
string? ColorLabel,
|
string? ColorLabel,
|
||||||
IReadOnlyList<Layer> Layers);
|
IReadOnlyList<Layer> Layers,
|
||||||
|
float Condition,
|
||||||
|
string ConditionLabel);
|
||||||
|
|
||||||
private sealed record Layer(string DefName, string Label);
|
private sealed record Layer(string DefName, string Label);
|
||||||
|
|
||||||
|
|||||||
@@ -63,4 +63,20 @@ public class BehaviorDefTests
|
|||||||
|
|
||||||
Assert.Contains("carry mass", error.Message, StringComparison.OrdinalIgnoreCase);
|
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(6f, catalog.BehaviorRules.LunchWeight);
|
||||||
Assert.Equal(5f, catalog.BehaviorRules.CarryMassBase);
|
Assert.Equal(5f, catalog.BehaviorRules.CarryMassBase);
|
||||||
Assert.Equal(0.4f, catalog.BehaviorRules.OptionalApparelChance);
|
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>
|
/// <summary>
|
||||||
@@ -147,6 +151,12 @@ public class VanillaCoreTests
|
|||||||
keys.Add(BodyBuilds.Attribute);
|
keys.Add(BodyBuilds.Attribute);
|
||||||
keys.AddRange(BodyBuilds.Values);
|
keys.AddRange(BodyBuilds.Values);
|
||||||
keys.AddRange(ApparelLayers.All);
|
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))
|
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