From 6be4fa75abe7511b4a753e8679c30bcae6c2b4a5 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 04:00:38 +0300 Subject: [PATCH] Split the person card into overview, clothes, carry and now tabs. Today's history is a paged HTTP log on the worker, not on the card or in people.json. Co-authored-by: Cursor --- docs/phases/36-person-card-tabs.md | 33 +- docs/protocol.md | 52 ++- src/HSchool.Client/src/i18n/strings.ts | 30 ++ src/HSchool.Client/src/net/api.ts | 48 +++ src/HSchool.Client/src/style.css | 88 ++++- src/HSchool.Client/src/ui/applicantsDialog.ts | 70 +++- .../src/ui/managementPanel.test.ts | 3 + src/HSchool.Client/src/ui/managementPanel.ts | 83 ++++- src/HSchool.Client/src/ui/peoplePanel.test.ts | 1 + src/HSchool.Client/src/ui/peoplePanel.ts | 82 ++++- src/HSchool.Client/src/ui/personCard.test.ts | 97 +++++- src/HSchool.Client/src/ui/personCard.ts | 322 +++++++++++++++--- src/HSchool.Server/Api/PeopleModels.cs | 18 +- src/HSchool.Server/Api/SchoolEndpoints.cs | 97 +++++- src/HSchool.Server/Game/GameCommand.cs | 8 + src/HSchool.Server/Game/GameLoopService.cs | 13 + src/HSchool.Server/Game/PersonCardReader.cs | 8 +- src/HSchool.Server/Game/PersonLogReader.cs | 30 ++ src/HSchool.Server/Game/SchoolWorker.cs | 14 + src/HSchool.Server/Game/WorkerCommand.cs | 7 + .../mods/core/localizations/en.jsonc | 3 + .../mods/core/localizations/ru.jsonc | 3 + .../HSchool.Simulation.csproj | 4 + src/HSchool.Simulation/PersonLog.cs | 179 ++++++++++ src/HSchool.Simulation/School.cs | 33 +- .../PersonLogApiTests.cs | 106 ++++++ .../PersonDayLogTests.cs | 228 +++++++++++++ 27 files changed, 1551 insertions(+), 109 deletions(-) create mode 100644 src/HSchool.Server/Game/PersonLogReader.cs create mode 100644 src/HSchool.Simulation/PersonLog.cs create mode 100644 tests/HSchool.AppHost.Tests/PersonLogApiTests.cs create mode 100644 tests/HSchool.Simulation.Tests/PersonDayLogTests.cs diff --git a/docs/phases/36-person-card-tabs.md b/docs/phases/36-person-card-tabs.md index f633755..3b9b487 100644 --- a/docs/phases/36-person-card-tabs.md +++ b/docs/phases/36-person-card-tabs.md @@ -13,29 +13,30 @@ ## Задачи -- [ ] Окно карточки: вкладки **Обзор / Одежда / Ноша / Сейчас** -- [ ] Обзор — прежнее: тело, навыки, черты, нужды, семья, расписание; текущее дело с обзора +- [x] Окно карточки: вкладки **Обзор / Одежда / Ноша / Сейчас** +- [x] Обзор — прежнее: тело, навыки, черты, нужды, семья, расписание; текущее дело с обзора уходит на «Сейчас» -- [ ] Одежда: слои, цвет, полоска износа и подпись, короткая уместность к ситуации -- [ ] Ноша: сумка, несёшь/можешь, шкафчик или его отсутствие, кратко «ещё N дома» без полного +- [x] Одежда: слои, цвет, полоска износа и подпись (если сервер прислал `condition` / + `conditionLabel`), короткая уместность — прочерк до фазы 35 +- [x] Ноша: сумка, несёшь/можешь, шкафчик или его отсутствие, кратко «ещё N дома» без полного домашнего списка -- [ ] Сейчас: на территории школы — подпись действия (`ест`, `идёт`, `учится (математика)`, +- [x] Сейчас: на территории школы — подпись действия (`ест`, `идёт`, `учится (математика)`, `переодевается`). Вне школы — «дома» -- [ ] История за сегодня под текущим делом: поиск, сортировка, пейджинг -- [ ] `GET /api/schools/{id}/people/{personId}/log` — страница и фильтр; не сокет, не лента +- [x] История за сегодня под текущим делом: поиск, сортировка, пейджинг +- [x] `GET /api/schools/{id}/people/{personId}/log` — страница и фильтр; не сокет, не лента школы. Граница суток — шесть утра; skip ночи обнуляет вчерашнее -- [ ] Запись в лог: начало/конец действия, утренняя выдача одежды, переодевание. Хранит - работник, не `people.json` -- [ ] Vitest: переключение вкладок, пейджер лога, «дома» когда activity пустая и человек away -- [ ] `docs/protocol.md` — HTTP лога в том же коммите +- [ ] Запись в лог: начало/конец действия есть; утренняя выдача одежды — тип + `apparel-replaced` подхватывается, пишет фаза 34; переодевание — после фазы 35 +- [x] Vitest: переключение вкладок, пейджер лога, «дома» когда activity пустая и человек away +- [x] `docs/protocol.md` — HTTP лога в том же коммите ## Тесты, без которых фаза не закрыта -- [ ] Лог не едет в `GET .../people/{id}` — только отдельным запросом -- [ ] Страница лога не пересекается; неизвестный человек — `404` -- [ ] После 6:00 записей вчера нет -- [ ] Вне школы вкладка «Сейчас» показывает «дома», не пусто -- [ ] Клиентский тест: вкладка «Одежда» не монтирует таблицу лога +- [x] Лог не едет в `GET .../people/{id}` — только отдельным запросом +- [x] Страница лога не пересекается; неизвестный человек — `404` +- [x] После 6:00 записей вчера нет +- [x] Вне школы вкладка «Сейчас» показывает «дома», не пусто +- [x] Клиентский тест: вкладка «Одежда» не монтирует таблицу лога ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 1edfc82..54922f5 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -255,10 +255,13 @@ 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, and `condition` +(0–1). `conditionLabel` is the catalog band when phase 34 has filled it; otherwise `null` — +the client does not invent a caption from the number. `carried` is the bag — textbooks include +`subject`. `carryMass` / `carryCapacity` are kilograms; overload does not slow walking. +`hasLocker` is true when anything sits in a locker; `homeCount` is how many items remain at +home, not the list. The people list does not include any of these fields. Today's history is +a separate GET. ```json { @@ -289,7 +292,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": null } ], "carried": [ @@ -304,7 +309,42 @@ people list does not include any of these fields. } ], "carryMass": 1.2, - "carryCapacity": 14 + "carryCapacity": 14, + "hasLocker": false, + "homeCount": 3 +} +``` + +### `GET /api/schools/{id}/people/{personId}/log` + +Today's history for one person: search, sort by time, page. Goes through the school's mailbox +because the log lives on the worker, not in `people.json` and not on a published snapshot. +Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`. This is +not a socket feed and not a school-wide event list. + +The day boundary is six in the morning — the same hour skip lands on. Crossing that hour, or +skipping an empty night, drops yesterday's rows. `?lang=ru|en` labels the captions. `q` is a +substring of the caption or type. `sort` is `time`. `dir` is `asc` or `desc` (default `desc`). +`page` starts at 1; `pageSize` defaults to 20 and is at most 100. A thousand rows do not +arrive in one response. + +Row `type` values: `action-started`, `action-ended`, `apparel-replaced` (morning issue, when +phase 34 appends it), `apparel-changed` (dressing, when phase 35 appends it). `thingDef` is +the action or apparel def the caption was built from. + +```json +{ + "total": 2, + "page": 1, + "pageSize": 20, + "entries": [ + { + "time": "2012-04-03T12:00:00Z", + "type": "action-started", + "label": "начал: Обед", + "thingDef": "EatLunch" + } + ] } ``` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 4e9e476..cafdda3 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -136,6 +136,21 @@ const ru = { peopleChildren: 'Дети', peopleSiblings: 'Братья и сёстры', peoplePartners: 'Супруг(а)', + peopleTabOverview: 'Обзор', + peopleTabApparel: 'Одежда', + peopleTabCarry: 'Ноша', + peopleTabNow: 'Сейчас', + peopleAtHome: 'дома', + peopleApparelFit: 'Уместность: {value}', + peopleLockerYes: 'Есть шкафчик', + peopleLockerNone: 'Шкафчика нет', + peopleHomeCount: 'ещё {n} дома', + peopleLogSearch: 'Поиск', + peopleLogNewest: 'Сначала новые', + peopleLogOldest: 'Сначала старые', + peopleLogTime: 'Время', + peopleLogEvent: 'Событие', + peopleLogEmpty: 'Сегодня записей нет.', modeOverview: 'Обзор', modeManage: 'Управление', @@ -356,6 +371,21 @@ const en: Messages = { peopleChildren: 'Children', peopleSiblings: 'Siblings', peoplePartners: 'Spouse', + peopleTabOverview: 'Overview', + peopleTabApparel: 'Clothes', + peopleTabCarry: 'Carried', + peopleTabNow: 'Now', + peopleAtHome: 'home', + peopleApparelFit: 'Fit: {value}', + peopleLockerYes: 'Has a locker', + peopleLockerNone: 'No locker', + peopleHomeCount: '{n} more at home', + peopleLogSearch: 'Search', + peopleLogNewest: 'Newest first', + peopleLogOldest: 'Oldest first', + peopleLogTime: 'Time', + peopleLogEvent: 'Event', + peopleLogEmpty: 'Nothing logged today.', modeOverview: 'Overview', modeManage: 'Management', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 9da73ac..04d118f 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -295,6 +295,8 @@ export interface PersonCard { readonly carried: readonly CarriedItem[]; readonly carryMass: number; readonly carryCapacity: number; + readonly hasLocker: boolean; + readonly homeCount: number; } export interface WornItem { @@ -303,6 +305,8 @@ export interface WornItem { readonly color: string | null; readonly colorLabel: string | null; readonly layers: readonly DefLabel[]; + readonly condition?: number; + readonly conditionLabel?: string | null; } export interface CarriedItem { @@ -335,6 +339,50 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri ); } +export type PersonLogDir = 'asc' | 'desc'; + +export interface PersonLogQuery { + readonly q?: string; + readonly sort?: 'time'; + readonly dir?: PersonLogDir; + readonly page?: number; + readonly pageSize?: number; +} + +export interface PersonLogEntry { + readonly time: string; + readonly type: string; + readonly label: string; + readonly thingDef: string | null; +} + +export interface PersonLogPage { + readonly total: number; + readonly page: number; + readonly pageSize: number; + readonly entries: readonly PersonLogEntry[]; +} + +export async function fetchPersonLog( + schoolId: number, + personId: string, + lang: string, + query: PersonLogQuery, +): Promise { + const params = new URLSearchParams({ lang }); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === '') { + continue; + } + + params.set(key, String(value)); + } + + return request( + `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/log?${params.toString()}`, + ); +} + export interface DirectoryPerson { readonly id: string; readonly fullName: string; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 24b7e1b..cb5e93b 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -593,11 +593,93 @@ body { } .people__card-place, -.people__card-activity { +.people__card-activity, +.people__now-activity { margin: 4px 0 0; color: var(--text-muted); } +.people__tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 10px 0 8px; +} + +.people__tab { + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font: inherit; + padding: 3px 8px; +} + +.people__tab:hover { + color: var(--text); +} + +.people__tab--active { + background: var(--surface); + color: var(--text); + border-color: var(--text-muted); +} + +.people__tab-panel[hidden] { + display: none; +} + +.people__fit, +.people__locker, +.people__home-count { + margin: 0 0 6px; + font-size: 12px; +} + +.people__garments { + display: flex; + flex-direction: column; + gap: 8px; +} + +.people__wear { + display: grid; + grid-template-columns: minmax(4em, auto) 1fr; + gap: 6px 8px; + align-items: center; + margin-top: 4px; + font-size: 12px; +} + +.people__wear-label { + color: var(--text-muted); +} + +.people__wear .people__need-track { + margin: 0; +} + +.people__log-tools { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} + +.people__log { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.people__log th, +.people__log td { + padding: 4px 6px; + text-align: left; + border-bottom: 1px solid var(--border); +} + .people__section { margin-top: 10px; } @@ -625,8 +707,8 @@ body { border-top: 1px solid var(--border); } -/* The first block sits right under the name, where a divider would only add noise. */ -.people__card > .people__card-meta + .people__section > .people__section-title { +/* The first block sits right under the tabs, where a divider would only add noise. */ +.people__tab-panel > .people__section:first-child > .people__section-title { margin-top: 0; padding-top: 0; border-top: 0; diff --git a/src/HSchool.Client/src/ui/applicantsDialog.ts b/src/HSchool.Client/src/ui/applicantsDialog.ts index a934bd0..c444502 100644 --- a/src/HSchool.Client/src/ui/applicantsDialog.ts +++ b/src/HSchool.Client/src/ui/applicantsDialog.ts @@ -1,7 +1,10 @@ import { fetchPerson, + fetchPersonLog, hireStaff, type PersonCard, + type PersonLogPage, + type PersonLogQuery, type Staffing, type StaffingApplicant, } from '../net/api.ts'; @@ -9,7 +12,7 @@ import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; import { Modal } from './modal.ts'; -import { formatPersonPlace, renderPersonCard } from './personCard.ts'; +import { formatPersonPlace, renderPersonCard, type PersonCardTab } from './personCard.ts'; import { actionError, fillSelect, @@ -69,6 +72,10 @@ export class ApplicantsDialog { private cardToken = 0; private busy = false; private painted: PersonCard | null = null; + private cardTab: PersonCardTab = 'overview'; + private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' }; + private logPage: PersonLogPage | null = null; + private logToken = 0; constructor(private readonly options: ApplicantsDialogOptions) { this.staffing = options.staffing; @@ -292,6 +299,9 @@ export class ApplicantsDialog { private async openCard(personId: string): Promise { const token = ++this.cardToken; + this.cardTab = 'overview'; + this.logPage = null; + this.logQuery = { page: 1, pageSize: 20, dir: 'desc' }; try { const card = await fetchPerson(this.options.schoolId, personId, getLocale()); if (token !== this.cardToken) { @@ -317,10 +327,66 @@ export class ApplicantsDialog { return; } - renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id)); + renderPersonCard(this.card, card, (id) => this.openRelative(id), this.cardOptions(card)); this.appendHireIfNeeded(); } + private cardOptions(card: PersonCard) { + const place = this.placeOf(card.id); + return { + place, + away: place === formatPersonPlace('away'), + tab: this.cardTab, + log: this.cardTab === 'now' ? this.logPage : null, + logQuery: this.logQuery, + onTab: (tab: PersonCardTab) => { + this.cardTab = tab; + if (tab === 'now') { + void this.loadLog(); + } else { + this.paintCard(this.painted); + } + }, + onLogSearch: (q: string) => { + this.logQuery = { ...this.logQuery, q, page: 1 }; + void this.loadLog(); + }, + onLogDir: (dir: 'asc' | 'desc') => { + this.logQuery = { ...this.logQuery, dir, page: 1 }; + void this.loadLog(); + }, + onLogPage: (page: number) => { + this.logQuery = { ...this.logQuery, page }; + void this.loadLog(); + }, + }; + } + + private async loadLog(): Promise { + const painted = this.painted; + if (painted === null || this.cardTab !== 'now') { + this.paintCard(painted); + return; + } + + const token = ++this.logToken; + try { + this.logPage = await fetchPersonLog(this.options.schoolId, painted.id, getLocale(), this.logQuery); + if (token !== this.logToken) { + return; + } + + this.paintCard(this.painted); + } catch { + if (token !== this.logToken) { + return; + } + + this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] }; + this.paintCard(this.painted); + } + } + private appendHireIfNeeded(): void { const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id); if (applicant === undefined) { diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index b0ba4e9..e37942b 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -24,6 +24,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { fetchStaffing: vi.fn(), fetchTimetable: vi.fn(), fetchPerson: vi.fn(), + fetchPersonLog: vi.fn(), hireStaff: vi.fn(), assignSubject: vi.fn(), unassignSubject: vi.fn(), @@ -59,6 +60,8 @@ function personCard(): PersonCard { carried: [], carryMass: 0, carryCapacity: 0, + hasLocker: false, + homeCount: 0, }; } diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 2d2094a..dc26bf4 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -1,10 +1,13 @@ import { assignSubject, fetchPerson, + fetchPersonLog, fetchStaffing, fetchTimetable, unassignSubject, type PersonCard, + type PersonLogPage, + type PersonLogQuery, type Staffing, type StaffMember, type Timetable, @@ -13,7 +16,7 @@ import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { ApplicantsDialog } from './applicantsDialog.ts'; import { clear, el } from './dom.ts'; -import { formatPersonPlace, renderPersonCard } from './personCard.ts'; +import { formatPersonPlace, nowActivityText, renderPersonCard, type PersonCardTab } from './personCard.ts'; import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; @@ -64,6 +67,10 @@ export class ManagementPanel { private locate: ((id: string) => string) | null = null; private painted: PersonCard | null = null; private poolDialog: ApplicantsDialog | null = null; + private cardTab: PersonCardTab = 'overview'; + private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' }; + private logPage: PersonLogPage | null = null; + private logToken = 0; constructor() { this.error.hidden = true; @@ -381,6 +388,9 @@ export class ManagementPanel { } const token = ++this.cardToken; + this.cardTab = 'overview'; + this.logPage = null; + this.logQuery = { page: 1, pageSize: 20, dir: 'desc' }; try { const card = await fetchPerson(schoolId, personId, getLocale()); if (token !== this.cardToken) { @@ -406,18 +416,80 @@ export class ManagementPanel { return; } - renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id)); + renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.cardOptions(card)); this.mountPersonTimetable(card); this.appendActions(card.id); } + private cardOptions(card: PersonCard) { + const place = this.placeOf(card.id); + return { + place, + away: place === formatPersonPlace('away'), + tab: this.cardTab, + log: this.cardTab === 'now' ? this.logPage : null, + logQuery: this.logQuery, + onTab: (tab: PersonCardTab) => { + this.cardTab = tab; + if (tab === 'now') { + void this.loadLog(); + } else { + this.paintCard(this.painted); + } + }, + onLogSearch: (q: string) => { + this.logQuery = { ...this.logQuery, q, page: 1 }; + void this.loadLog(); + }, + onLogDir: (dir: 'asc' | 'desc') => { + this.logQuery = { ...this.logQuery, dir, page: 1 }; + void this.loadLog(); + }, + onLogPage: (page: number) => { + this.logQuery = { ...this.logQuery, page }; + void this.loadLog(); + }, + }; + } + + private async loadLog(): Promise { + const schoolId = this.schoolId; + const painted = this.painted; + if (schoolId === null || painted === null || this.cardTab !== 'now') { + this.paintCard(painted); + return; + } + + const token = ++this.logToken; + try { + this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery); + if (token !== this.logToken) { + return; + } + + this.paintCard(this.painted); + } catch { + if (token !== this.logToken) { + return; + } + + this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] }; + this.paintCard(this.painted); + } + } + private relocate(): void { const line = this.card.querySelector('.people__card-place'); if (!(line instanceof HTMLElement) || this.painted === null) { return; } - line.textContent = this.placeOf(this.painted.id); + const place = this.placeOf(this.painted.id); + line.textContent = place; + const now = this.card.querySelector('.people__now-activity'); + if (now instanceof HTMLElement) { + now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away')); + } } private placeOf(id: string): string { @@ -427,11 +499,12 @@ export class ManagementPanel { private mountPersonTimetable(card: PersonCard): void { const schoolId = this.schoolId; const query = personTimetableQuery(card); - if (schoolId === null || query === null) { + const overview = this.card.querySelector('[data-card-tab="overview"]'); + if (!(overview instanceof HTMLElement) || schoolId === null || query === null) { return; } - this.card.append(this.personTimetableTitle, this.personGrid.element); + overview.append(this.personTimetableTitle, this.personGrid.element); this.personGrid.attach(schoolId); const token = this.cardToken; void fetchTimetable(schoolId, getLocale(), query) diff --git a/src/HSchool.Client/src/ui/peoplePanel.test.ts b/src/HSchool.Client/src/ui/peoplePanel.test.ts index d6f2185..6e81d58 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.test.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.test.ts @@ -20,6 +20,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { ...actual, fetchPeople: vi.fn(), fetchPerson: vi.fn(), + fetchPersonLog: vi.fn(), fetchTimetable: vi.fn(), }; }); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index e20ab8f..4868eb1 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -1,8 +1,8 @@ -import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonCard, type PersonRole, type PersonSort } from '../net/api.ts'; +import { fetchPeople, fetchPerson, fetchPersonLog, fetchTimetable, type PeoplePage, type PersonCard, type PersonLogPage, type PersonLogQuery, type PersonRole, type PersonSort } from '../net/api.ts'; import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; -import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts'; +import { formatPersonPlace, nowActivityText, placement, renderPersonCard, roleLabels, type PersonCardTab } from './personCard.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ @@ -62,6 +62,10 @@ export class PeoplePanel { private cardToken = 0; private locate: ((id: string) => string) | null = null; private painted: PersonCard | null = null; + private cardTab: PersonCardTab = 'overview'; + private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' }; + private logPage: PersonLogPage | null = null; + private logToken = 0; constructor(private readonly options: PeoplePanelOptions) { this.ageMinInput.min = '0'; @@ -340,6 +344,9 @@ export class PeoplePanel { // Also covers the links inside a card: opening a relative is picking somebody too. this.options.onSelect(); + this.cardTab = 'overview'; + this.logPage = null; + this.logQuery = { page: 1, pageSize: 20, dir: 'desc' }; const token = ++this.cardToken; try { const card = await fetchPerson(schoolId, personId, getLocale()); @@ -368,17 +375,79 @@ export class PeoplePanel { return; } - renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id)); + renderPersonCard(this.card, card, (id) => void this.openCard(id), this.cardOptions(card)); this.mountPersonTimetable(card); } + private cardOptions(card: PersonCard) { + const place = this.placeOf(card.id); + return { + place, + away: place === formatPersonPlace('away'), + tab: this.cardTab, + log: this.cardTab === 'now' ? this.logPage : null, + logQuery: this.logQuery, + onTab: (tab: PersonCardTab) => { + this.cardTab = tab; + if (tab === 'now') { + void this.loadLog(); + } else { + this.paintCard(this.painted); + } + }, + onLogSearch: (q: string) => { + this.logQuery = { ...this.logQuery, q, page: 1 }; + void this.loadLog(); + }, + onLogDir: (dir: 'asc' | 'desc') => { + this.logQuery = { ...this.logQuery, dir, page: 1 }; + void this.loadLog(); + }, + onLogPage: (page: number) => { + this.logQuery = { ...this.logQuery, page }; + void this.loadLog(); + }, + }; + } + + private async loadLog(): Promise { + const schoolId = this.schoolId; + const painted = this.painted; + if (schoolId === null || painted === null || this.cardTab !== 'now') { + this.paintCard(painted); + return; + } + + const token = ++this.logToken; + try { + this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery); + if (token !== this.logToken) { + return; + } + + this.paintCard(this.painted); + } catch { + if (token !== this.logToken) { + return; + } + + this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] }; + this.paintCard(this.painted); + } + } + private relocate(): void { const line = this.card.querySelector('.people__card-place'); if (!(line instanceof HTMLElement) || this.painted === null) { return; } - line.textContent = this.placeOf(this.painted.id); + const place = this.placeOf(this.painted.id); + line.textContent = place; + const now = this.card.querySelector('.people__now-activity'); + if (now instanceof HTMLElement) { + now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away')); + } } private placeOf(id: string): string { @@ -388,11 +457,12 @@ export class PeoplePanel { private mountPersonTimetable(card: PersonCard): void { const schoolId = this.schoolId; const query = personTimetableQuery(card); - if (schoolId === null || query === null) { + const overview = this.card.querySelector('[data-card-tab="overview"]'); + if (!(overview instanceof HTMLElement) || schoolId === null || query === null) { return; } - this.card.append(this.personTimetableTitle, this.personGrid.element); + overview.append(this.personTimetableTitle, this.personGrid.element); this.personGrid.attach(schoolId); const token = this.cardToken; void fetchTimetable(schoolId, getLocale(), query) diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index bcb2647..5b1989f 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -1,11 +1,11 @@ /** * @vitest-environment happy-dom */ -import { afterEach, describe, expect, it } from 'vitest'; -import type { PersonCard } from '../net/api.ts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PersonCard, PersonLogPage } from '../net/api.ts'; import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; -import { renderPersonCard } from './personCard.ts'; +import { renderPersonCard, type PersonCardTab } from './personCard.ts'; const initial = getLocale(); @@ -27,7 +27,7 @@ function card(overrides: Partial = {}): PersonCard { classId: 'class-1', position: null, positionLabel: null, - body: [], + body: [{ id: 'Height', label: 'Рост', value: '164' }], skills: [], traits: [], needs: [], @@ -41,6 +41,7 @@ function card(overrides: Partial = {}): PersonCard { color: 'White', colorLabel: 'Белый', layers: [{ defName: 'Top', label: 'Верх' }], + condition: 1, }, ], carried: [ @@ -56,22 +57,100 @@ function card(overrides: Partial = {}): PersonCard { ], carryMass: 1.2, carryCapacity: 14, + hasLocker: false, + homeCount: 2, ...overrides, }; } +function logPage(overrides: Partial = {}): PersonLogPage { + return { + total: 3, + page: 1, + pageSize: 2, + entries: [ + { time: '2012-04-03T12:00:00Z', type: 'action-started', label: 'начал: Обед', thingDef: 'EatLunch' }, + { time: '2012-04-03T12:15:00Z', type: 'action-ended', label: 'закончил: Обед', thingDef: 'EatLunch' }, + ], + ...overrides, + }; +} + +function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement { + const button = [...root.querySelectorAll('button')].find((node) => node.dataset.tab === tab); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`missing tab ${tab}`); + } + + return button; +} + describe('renderPersonCard', () => { - it('shows worn layers with colour and carried mass in the same scroll', () => { + it('switches overview, clothes, carried and now', () => { setLocale('ru'); const root = document.createElement('div'); renderPersonCard(root, card(), () => {}); - expect(root.textContent).toContain(t('peopleApparel')); - expect(root.textContent).toContain('Верх'); + expect(root.querySelector('.people__tabs')).not.toBeNull(); + expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(false); + expect(root.textContent).toContain('Рост'); + expect(root.querySelector('.people__log')).toBeNull(); + + tabButton(root, 'apparel').click(); + expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false); + expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(true); expect(root.textContent).toContain('Рубашка · Белый'); - expect(root.textContent).toContain(t('peopleCarry')); + expect(root.querySelector('.people__log')).toBeNull(); + + tabButton(root, 'carry').click(); expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' })); + expect(root.textContent).toContain(t('peopleLockerNone')); + expect(root.textContent).toContain(t('peopleHomeCount', { n: 2 })); expect(root.textContent).toContain('Учебник · Математика'); - expect(root.querySelector('.people__tabs')).toBeNull(); + + tabButton(root, 'now').click(); + expect(root.querySelector('[data-card-tab="now"]')?.hasAttribute('hidden')).toBe(false); + }); + + it('does not mount the log table on the clothes tab', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card(), () => {}, { tab: 'apparel', log: logPage() }); + + expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false); + expect(root.querySelector('.people__log')).toBeNull(); + expect(root.querySelector('.people__log-tools')).toBeNull(); + }); + + it('pages the log on the now tab', () => { + setLocale('ru'); + const onLogPage = vi.fn(); + const root = document.createElement('div'); + renderPersonCard(root, card(), () => {}, { + tab: 'now', + log: logPage(), + onLogPage, + }); + + expect(root.querySelector('.people__log')).not.toBeNull(); + expect(root.textContent).toContain('начал: Обед'); + expect(root.textContent).toContain(t('peoplePager', { page: 1, pages: 2, total: 3 })); + const next = [...root.querySelectorAll('.people__pager button')].at(-1); + expect(next).toBeInstanceOf(HTMLButtonElement); + (next as HTMLButtonElement).click(); + expect(onLogPage).toHaveBeenCalledWith(2); + }); + + it('shows home on now when activity is empty and the person is away', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card({ activity: null, activityLabel: null }), () => {}, { + tab: 'now', + away: true, + place: t('presenceAway'), + }); + + expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome')); + expect(root.querySelector('.people__now-activity')?.textContent).not.toBe(''); }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index b970160..97b89c2 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -1,4 +1,13 @@ -import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts'; +import type { + PersonCard, + PersonListItem, + PersonLogDir, + PersonLogPage, + PersonLogQuery, + PersonRel, + PersonRole, +} from '../net/api.ts'; +import { formatGameTimeOfDay } from '../format/gameTime.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { el } from './dom.ts'; @@ -8,6 +17,20 @@ const ROLE_KEYS: Record = { parent: 'peopleRoleParent', }; +export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now'; + +export interface RenderPersonCardOptions { + readonly place?: string; + readonly away?: boolean; + readonly tab?: PersonCardTab; + readonly log?: PersonLogPage | null; + readonly logQuery?: PersonLogQuery; + readonly onTab?: (tab: PersonCardTab) => void; + readonly onLogSearch?: (query: string) => void; + readonly onLogDir?: (dir: PersonLogDir) => void; + readonly onLogPage?: (page: number) => void; +} + export function roleLabels(roles: readonly string[]): string { return roles .map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role)) @@ -27,28 +50,90 @@ export function placement(person: Pick 0 ? parts.join(' · ') : '—'; } +export function nowActivityText(card: PersonCard, away: boolean): string { + if (away && (card.activityLabel === null || card.activityLabel.length === 0)) { + return t('peopleAtHome'); + } + + return card.activityLabel ?? ''; +} + export function renderPersonCard( parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void, - place?: string, + options: RenderPersonCardOptions = {}, ): void { + const tab = options.tab ?? 'overview'; parent.append( el('h3', { class: 'people__card-name', text: card.fullName }), el('p', { class: 'people__card-meta', text: cardMeta(card) }), ); - if (place !== undefined && place.length > 0) { - parent.append(el('p', { class: 'people__card-place', text: place })); + if (options.place !== undefined && options.place.length > 0) { + parent.append(el('p', { class: 'people__card-place', text: options.place })); } - if (card.activityLabel !== null && card.activityLabel.length > 0) { - parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel })); + + const tabs = el('div', { class: 'people__tabs' }); + const panels: Record = { + overview: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'overview' } }), + apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }), + carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }), + now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }), + }; + + const labels: Record = { + overview: 'peopleTabOverview', + apparel: 'peopleTabApparel', + carry: 'peopleTabCarry', + now: 'peopleTabNow', + }; + + const buttons: Partial> = {}; + for (const id of Object.keys(labels) as PersonCardTab[]) { + const button = el('button', { + class: 'people__tab', + type: 'button', + text: t(labels[id]), + dataset: { tab: id }, + onClick: () => { + showTab(id); + options.onTab?.(id); + }, + }); + button.classList.toggle('people__tab--active', id === tab); + buttons[id] = button; + tabs.append(button); } + + fillOverview(panels.overview, card, onRelative); + fillApparel(panels.apparel, card); + fillCarry(panels.carry, card); + fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options); + + const showTab = (next: PersonCardTab): void => { + for (const id of Object.keys(panels) as PersonCardTab[]) { + panels[id].hidden = id !== next; + buttons[id]?.classList.toggle('people__tab--active', id === next); + } + + const log = panels.now.querySelector('.people__log, .people__log-tools'); + if (next !== 'now' && log !== null) { + for (const node of [...panels.now.querySelectorAll('.people__log, .people__log-tools, .people__pager, .panel__empty')]) { + node.remove(); + } + } + }; + + showTab(tab); + + parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now); +} + +function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void { appendPairs(parent, t('peopleBody'), card.body); appendPairs(parent, t('peopleSkills'), card.skills); appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label)); appendNeeds(parent, t('peopleNeeds'), card.needs); - appendApparel(parent, card.worn); - appendCarry(parent, card); const family = section(t('peopleFamily')); appendRelatives(family, t('peopleParents'), card.family.parents, onRelative); @@ -60,6 +145,179 @@ export function renderPersonCard( } } +function fillApparel(parent: HTMLElement, card: PersonCard): void { + parent.append(el('p', { class: 'people__fit', text: t('peopleApparelFit', { value: '—' }) })); + if (card.worn.length === 0) { + return; + } + + const list = el('div', { class: 'people__garments' }); + for (const row of card.worn) { + 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; + const garment = el( + 'div', + { class: 'people__garment' }, + el( + 'div', + { class: 'people__pair' }, + el('dt', { text: layers.length > 0 ? layers : row.label }), + el('dd', { text: value }), + ), + ); + if (row.condition !== undefined) { + 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); + const caption = row.conditionLabel !== undefined && row.conditionLabel !== null && row.conditionLabel.length > 0 + ? row.conditionLabel + : '—'; + garment.append( + el( + 'div', + { class: 'people__wear' }, + el('span', { class: 'people__wear-label', text: caption }), + el('span', { class: 'people__need-track' }, fill), + ), + ); + } + + list.append(garment); + } + + parent.append(list); +} + +function fillCarry(parent: HTMLElement, card: PersonCard): void { + parent.append( + el('p', { + class: 'people__carry-mass', + text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }), + }), + el('p', { + class: 'people__locker', + text: card.hasLocker ? t('peopleLockerYes') : t('peopleLockerNone'), + }), + el('p', { + class: 'people__home-count', + text: t('peopleHomeCount', { n: card.homeCount }), + }), + ); + if (card.carried.length === 0) { + return; + } + + const grid = el('dl', { class: 'people__pairs' }); + for (const row of card.carried) { + const extra = row.subjectLabel ?? row.colorLabel; + const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label; + grid.append( + el( + 'div', + { class: 'people__pair' }, + el('dt', { text: value }), + el('dd', { text: formatMass(row.mass) }), + ), + ); + } + + parent.append(grid); +} + +function fillNow( + parent: HTMLElement, + card: PersonCard, + away: boolean, + log: PersonLogPage | null | undefined, + options: RenderPersonCardOptions, +): void { + const activity = nowActivityText(card, away); + if (activity.length > 0) { + parent.append(el('p', { class: 'people__now-activity', text: activity })); + } + + if (log === null || log === undefined) { + return; + } + + const search = el('input', { class: 'input people__input', type: 'search' }); + search.placeholder = t('peopleLogSearch'); + search.value = options.logQuery?.q ?? ''; + search.addEventListener('change', () => options.onLogSearch?.(search.value)); + + const dir = el('select', { class: 'input people__input' }); + const desc = el('option', { text: t('peopleLogNewest') }); + desc.value = 'desc'; + const asc = el('option', { text: t('peopleLogOldest') }); + asc.value = 'asc'; + dir.append(desc, asc); + dir.value = options.logQuery?.dir ?? 'desc'; + dir.addEventListener('change', () => options.onLogDir?.(dir.value === 'asc' ? 'asc' : 'desc')); + + const table = el('table', { class: 'people__log' }); + const body = el('tbody'); + table.append( + el( + 'thead', + {}, + el( + 'tr', + {}, + el('th', { text: t('peopleLogTime') }), + el('th', { text: t('peopleLogEvent') }), + ), + ), + body, + ); + if (log.entries.length === 0) { + parent.append( + el('div', { class: 'people__log-tools' }, search, dir), + el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }), + ); + return; + } + + for (const row of log.entries) { + body.append( + el( + 'tr', + {}, + el('td', { text: formatGameTimeOfDay(new Date(row.time)) }), + el('td', { text: row.label }), + ), + ); + } + + const pages = Math.max(1, Math.ceil(log.total / log.pageSize)); + const prev = el('button', { + class: 'button button--small', + type: 'button', + text: t('peoplePrev'), + disabled: log.page <= 1, + onClick: () => options.onLogPage?.(log.page - 1), + }); + const next = el('button', { + class: 'button button--small', + type: 'button', + text: t('peopleNext'), + disabled: log.page >= pages, + onClick: () => options.onLogPage?.(log.page + 1), + }); + parent.append( + el('div', { class: 'people__log-tools' }, search, dir), + table, + el( + 'div', + { class: 'people__pager' }, + prev, + el('span', { class: 'people__pager-label', text: t('peoplePager', { page: log.page, pages, total: log.total }) }), + next, + ), + ); +} + function cardMeta(card: PersonCard): string { const bits = [ roleLabels(card.roles), @@ -114,54 +372,6 @@ function appendPairs( parent.append(section(title), grid); } -function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void { - if (rows.length === 0) { - return; - } - - const grid = el('dl', { class: 'people__pairs' }); - 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( - el( - 'div', - { class: 'people__pair' }, - el('dt', { text: layers.length > 0 ? layers : row.label }), - el('dd', { text: value }), - ), - ); - } - - parent.append(section(t('peopleApparel')), grid); -} - -function appendCarry(parent: HTMLElement, card: PersonCard): void { - const mass = el('p', { - class: 'people__carry-mass', - text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }), - }); - const grid = el('dl', { class: 'people__pairs' }); - for (const row of card.carried) { - const extra = row.subjectLabel ?? row.colorLabel; - const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label; - grid.append( - el( - 'div', - { class: 'people__pair' }, - el('dt', { text: value }), - el('dd', { text: formatMass(row.mass) }), - ), - ); - } - - parent.append(section(t('peopleCarry')), mass); - if (card.carried.length > 0) { - parent.append(grid); - } -} - function formatMass(value: number): string { return String(Math.round(value * 100) / 100); } diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 2b97582..f290251 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -12,6 +12,16 @@ internal enum PersonLookupError internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error); +internal sealed record PersonLogResult(PersonLogResponse? Page, PersonLookupError Error); + +internal sealed record PersonLogResponse( + int Total, + int Page, + int PageSize, + IReadOnlyList Entries); + +internal sealed record PersonLogEntryResponse(DateTime Time, string Type, string Label, string? ThingDef); + internal sealed record PeopleListResponse( int Total, int Page, @@ -65,14 +75,18 @@ internal sealed record PersonCardResponse( IReadOnlyList Worn, IReadOnlyList Carried, float CarryMass, - float CarryCapacity); + float CarryCapacity, + bool HasLocker, + int HomeCount); internal sealed record WornItemResponse( string DefName, 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/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 3e31eb9..45d0afe 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -7,7 +7,7 @@ namespace HSchool.Server.Api; /// /// The main menu talks to these: list, create, delete. People list and staffing read published -/// snapshots; the person card, hire and subject changes go through the school's mailbox. +/// snapshots; the person card, the personal log, hire and subject changes go through the school's mailbox. /// The timetable is a published snapshot; pin/unpin go through the mailbox. /// internal static class SchoolEndpoints @@ -194,6 +194,41 @@ internal static class SchoolEndpoints }) .WithName("GetSchoolPerson"); + schools.MapGet("/{id:int}/people/{personId}/log", async ( + int id, + string personId, + string? q, + string? sort, + string? dir, + int? page, + int? pageSize, + string? lang, + GameCommandQueue commands, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid."); + } + + if (!TryParsePersonLogQuery(q, sort, dir, page, pageSize, out var query, out var error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.GetPersonLog(id, personId, ParseLocale(lang), query, NewCompletion()); + commands.Enqueue(command); + + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return outcome.Error switch + { + PersonLookupError.None when outcome.Page is not null => Results.Ok(outcome.Page), + PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."), + _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."), + }; + }) + .WithName("GetSchoolPersonLog"); + schools.MapGet("/{id:int}/staffing", ( int id, string? lang, @@ -396,6 +431,66 @@ internal static class SchoolEndpoints return true; } + private static bool TryParsePersonLogQuery( + string? search, + string? sort, + string? dir, + int? page, + int? pageSize, + out PersonLogQuery query, + out string error) + { + query = default; + error = string.Empty; + + if (search is { Length: > 128 }) + { + error = "q must be at most 128 characters."; + return false; + } + + if (!string.IsNullOrWhiteSpace(sort) && !sort.Equals("time", StringComparison.OrdinalIgnoreCase)) + { + error = "sort must be time."; + return false; + } + + var descending = true; + if (!string.IsNullOrWhiteSpace(dir)) + { + if (dir.Equals("asc", StringComparison.OrdinalIgnoreCase)) + { + descending = false; + } + else if (!dir.Equals("desc", StringComparison.OrdinalIgnoreCase)) + { + error = "dir must be asc or desc."; + return false; + } + } + + var parsedPage = page ?? 1; + if (parsedPage < 1) + { + error = "page must be 1 or greater."; + return false; + } + + var parsedPageSize = pageSize ?? PersonLogBrowser.DefaultPageSize; + if (parsedPageSize < 1 || parsedPageSize > PersonLogBrowser.MaxPageSize) + { + error = $"pageSize must be between 1 and {PersonLogBrowser.MaxPageSize}."; + return false; + } + + query = new PersonLogQuery( + string.IsNullOrWhiteSpace(search) ? null : search.Trim(), + descending, + parsedPage, + parsedPageSize); + return true; + } + private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) => StaffingMapper.From( published.Roster, diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index a0f4e64..2b975d1 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -59,6 +59,14 @@ internal abstract record GameCommand string Locale, TaskCompletionSource Result) : GameCommand; + /// Today's history for one person. Completes on that school's worker; not a snapshot. + internal sealed record GetPersonLog( + int SchoolId, + string PersonId, + string Locale, + PersonLogQuery Query, + TaskCompletionSource Result) : GameCommand; + internal sealed record HireStaff( int SchoolId, string PersonId, diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 312d409..14ff3b7 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -187,6 +187,10 @@ internal sealed class GameLoopService( HandleGetPerson(getPerson); break; + case GameCommand.GetPersonLog getLog: + HandleGetPersonLog(getLog); + break; + case GameCommand.HireStaff hire: HandleStaffing( hire.SchoolId, @@ -242,6 +246,15 @@ internal sealed class GameLoopService( } } + private void HandleGetPersonLog(GameCommand.GetPersonLog command) + { + if (!_workers.TryGetValue(command.SchoolId, out var worker) + || !worker.Post(new WorkerCommand.GetPersonLog(command.PersonId, command.Locale, command.Query, command.Result))) + { + command.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool)); + } + } + private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index fe5d759..0d4952f 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -85,7 +85,9 @@ internal static class PersonCardReader Worn(person, catalog, locale), Carried(person, catalog, locale), catalog is null ? 0f : CarryMass.Held(catalog, person.Items), - Capacity(person, skills, catalog)); + Capacity(person, skills, catalog), + person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)), + person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal))); } private static IReadOnlyDictionary? LiveNeeds(World world, string personId) @@ -288,7 +290,9 @@ internal static class PersonCardReader ThingLabel(catalog, locale, item.Def), item.Color, ColorLabel(catalog, locale, item.Color), - layers)); + layers, + item.Condition, + ConditionLabel: null)); } return rows; diff --git a/src/HSchool.Server/Game/PersonLogReader.cs b/src/HSchool.Server/Game/PersonLogReader.cs new file mode 100644 index 0000000..febaf7f --- /dev/null +++ b/src/HSchool.Server/Game/PersonLogReader.cs @@ -0,0 +1,30 @@ +using HSchool.Server.Api; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +/// Pages today's log on the worker thread. The list is not a published snapshot. +internal static class PersonLogReader +{ + public static PersonLogResponse? Read(School school, string personId, PersonLogQuery query, string locale) + { + var roster = school.Roster; + if (roster is null) + { + return null; + } + + var known = roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal)) + || (school.Applicants?.Applicants.Any(applicant => applicant.Person.Id.Equals(personId, StringComparison.Ordinal)) ?? false); + if (!known) + { + return null; + } + + var page = PersonLogBrowser.Apply(school.DayLog, personId, query, school.Catalog, locale); + var entries = page.Entries + .Select(row => new PersonLogEntryResponse(row.Time, row.Type, row.Label, row.ThingDef)) + .ToArray(); + return new PersonLogResponse(page.Total, page.Page, page.PageSize, entries); + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index ab9bfc0..80804f5 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -429,6 +429,14 @@ internal sealed class SchoolWorker : new PersonCardResult(card, PersonLookupError.None)); break; + case WorkerCommand.GetPersonLog getLog: + var log = PersonLogReader.Read(school, getLog.PersonId, getLog.Query, getLog.Locale); + getLog.Result.TrySetResult( + log is null + ? new PersonLogResult(null, PersonLookupError.UnknownPerson) + : new PersonLogResult(log, PersonLookupError.None)); + break; + case WorkerCommand.HireStaff hire: hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position)); break; @@ -485,6 +493,9 @@ internal sealed class SchoolWorker case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); break; + case WorkerCommand.GetPersonLog getLog: + getLog.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool)); + break; case WorkerCommand.HireStaff hire: hire.Result.TrySetResult(Staffing.UnknownSchool()); break; @@ -513,6 +524,9 @@ internal sealed class SchoolWorker case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetException(exception); break; + case WorkerCommand.GetPersonLog getLog: + getLog.Result.TrySetException(exception); + break; case WorkerCommand.HireStaff hire: hire.Result.TrySetException(exception); break; diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 5b263ae..5fdbda5 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -1,6 +1,7 @@ using HSchool.People; using HSchool.Server.Api; using HSchool.Server.Net; +using HSchool.Simulation; namespace HSchool.Server.Game; @@ -27,6 +28,12 @@ internal abstract record WorkerCommand string Locale, TaskCompletionSource Result) : WorkerCommand; + internal sealed record GetPersonLog( + string PersonId, + string Locale, + PersonLogQuery Query, + TaskCompletionSource Result) : WorkerCommand; + internal sealed record HireStaff( string PersonId, string Position, diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 0163aff..02a2ffa 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -159,5 +159,8 @@ "WinterBreak": "Winter break", "SpringBreak": "Spring break", "SummerBreak": "Summer break", + "ActionStarted": "started: {0}", + "ActionEnded": "finished: {0}", + "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 c910028..0ce4d60 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -159,5 +159,8 @@ "WinterBreak": "Зимние каникулы", "SpringBreak": "Весенние каникулы", "SummerBreak": "Летние каникулы", + "ActionStarted": "начал: {0}", + "ActionEnded": "закончил: {0}", + "ApparelReplaced": "получил новую {0}", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj index 6f95035..2a40490 100644 --- a/src/HSchool.Simulation/HSchool.Simulation.csproj +++ b/src/HSchool.Simulation/HSchool.Simulation.csproj @@ -9,6 +9,10 @@ + + + + diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs new file mode 100644 index 0000000..59984f2 --- /dev/null +++ b/src/HSchool.Simulation/PersonLog.cs @@ -0,0 +1,179 @@ +using System.Globalization; +using Arch.Core; +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// Log row types the person card pages. Captions for morning issue are ready even while phase 34 +/// is the one that appends that row. +/// +public static class PersonLogTypes +{ + public const string ActionStarted = "action-started"; + public const string ActionEnded = "action-ended"; + public const string ApparelReplaced = "apparel-replaced"; + public const string ApparelChanged = "apparel-changed"; +} + +/// +/// One thing that happened to a person today. Stored on the school 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 (ThingDef is null) + { + return Type; + } + + if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal)) + { + var name = catalog.Things.TryGetValue(ThingDef, out var def) + ? catalog.Label(locale, def) + : ThingDef; + return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name); + } + + if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal) + || Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal)) + { + var name = catalog.Actions.TryGetValue(ThingDef, out var action) + ? catalog.Label(locale, action) + : ThingDef; + var key = Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal) + ? "ActionStarted" + : "ActionEnded"; + return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); + } + + return Type; + } +} + +public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize); + +public sealed record PersonLogPage(int Total, int Page, int PageSize, IReadOnlyList Entries); + +public sealed record PersonLogRow(DateTime Time, string Type, string Label, string? ThingDef); + +/// Filters, sorts and pages today's log for one person. Does not touch a World. +public static class PersonLogBrowser +{ + public const int DefaultPageSize = 20; + + public const int MaxPageSize = 100; + + public static PersonLogPage Apply( + IReadOnlyList log, + string personId, + PersonLogQuery query, + DefCatalog? catalog, + string locale) + { + ArgumentNullException.ThrowIfNull(log); + ArgumentException.ThrowIfNullOrWhiteSpace(personId); + + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize); + var search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search.Trim(); + var rows = new List(); + foreach (var row in log) + { + if (!row.PersonId.Equals(personId, StringComparison.Ordinal)) + { + continue; + } + + var label = catalog is null ? row.Type : row.Caption(catalog, locale); + if (search is not null + && !label.Contains(search, StringComparison.OrdinalIgnoreCase) + && !row.Type.Contains(search, StringComparison.OrdinalIgnoreCase) + && (row.ThingDef is null || !row.ThingDef.Contains(search, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + rows.Add(new PersonLogRow(row.Time, row.Type, label, row.ThingDef)); + } + + rows.Sort((left, right) => + { + var byTime = left.Time.CompareTo(right.Time); + return query.Descending ? -byTime : byTime; + }); + + var skip = (page - 1) * pageSize; + var slice = skip >= rows.Count ? Array.Empty() : rows.Skip(skip).Take(pageSize).ToArray(); + return new PersonLogPage(rows.Count, page, pageSize, slice); + } +} + +/// Records action start/end and clears yesterday at six in the morning. +internal static class PersonDayLog +{ + private static readonly QueryDescription IdentityAndActivity = + new QueryDescription().WithAll(); + + 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; + } + + internal static void Sync(School school) + { + var world = school.World; + var seen = new HashSet(StringComparer.Ordinal); + var query = IdentityAndActivity; + world.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) => + { + seen.Add(identity.Id); + Record(school, identity.Id, activity.ActionId); + }); + + foreach (var id in school.LoggedActivity.Keys.ToArray()) + { + if (!seen.Contains(id)) + { + Record(school, id, current: null); + school.LoggedActivity.Remove(id); + } + } + } + + private static void Record(School school, string personId, string? current) + { + school.LoggedActivity.TryGetValue(personId, out var last); + if (string.Equals(last, current, StringComparison.Ordinal)) + { + school.LoggedActivity[personId] = current; + return; + } + + if (last is not null) + { + school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionEnded, last)); + } + + if (current is not null) + { + school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionStarted, current)); + } + + school.LoggedActivity[personId] = current; + } +} diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 1bd3e16..2685d39 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) { @@ -111,6 +112,21 @@ public sealed class School : IDisposable public int PendingDecisionCount => DecisionQueue.Count; + /// + /// Today's history for the person card. Cleared at six in the morning. Not written to disk. + /// + public IReadOnlyList DayLog => _dayLog; + + internal Dictionary LoggedActivity { get; } = new(StringComparer.Ordinal); + + internal void ResetDayLog() + { + _dayLog.Clear(); + LoggedActivity.Clear(); + } + + internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); + public void QueueDecision(string personId) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -137,6 +153,7 @@ public sealed class School : IDisposable LastDecisionSlot = null; Plans.Clear(); DecisionQueue.Clear(); + LoggedActivity.Clear(); } public bool TryStartAction(string personId, string actionId) @@ -144,7 +161,13 @@ public sealed class School : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(personId); ArgumentException.ThrowIfNullOrWhiteSpace(actionId); - return ActivitySystem.TryStart(this, personId, actionId); + var started = ActivitySystem.TryStart(this, personId, actionId); + if (started) + { + PersonDayLog.Sync(this); + } + + return started; } public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400) @@ -203,6 +226,7 @@ public sealed class School : IDisposable var before = Clock.Time; Clock.JumpTo(next.Value); + ResetDayLog(); var peopleChanged = TryYearlyIntake(before, next.Value); peopleChanged |= TryApplicantRefresh(); PlanDay = null; @@ -257,6 +281,11 @@ public sealed class School : IDisposable var peopleChanged = false; if (gameMinutes > 0) { + if (PersonDayLog.CrossedDayStart(before, Clock.Time)) + { + ResetDayLog(); + } + peopleChanged = TryYearlyIntake(before, Clock.Time); peopleChanged |= TryApplicantRefresh(); if (peopleChanged) @@ -271,6 +300,8 @@ public sealed class School : IDisposable PresenceSystem.Enqueue(this, id); } + PersonDayLog.Sync(this); + if (Catalog is not null) { var below = PresenceSystem.BelowThreshold(this); diff --git a/tests/HSchool.AppHost.Tests/PersonLogApiTests.cs b/tests/HSchool.AppHost.Tests/PersonLogApiTests.cs new file mode 100644 index 0000000..8504e8b --- /dev/null +++ b/tests/HSchool.AppHost.Tests/PersonLogApiTests.cs @@ -0,0 +1,106 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class PersonLogApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task Card_DoesNotIncludeTheLog() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Карточка без лога", Start, seed: 36); + + var page = await GetPeopleAsync(client, school.Id); + var id = page.People[0].Id; + using var response = await client.GetAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(id)}", + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.False(json.RootElement.TryGetProperty("entries", out _)); + Assert.False(json.RootElement.TryGetProperty("log", out _)); + + var log = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(id)}/log?pageSize=20", + TestContext.Current.CancellationToken); + Assert.NotNull(log); + Assert.Equal(1, log.Page); + Assert.Equal(20, log.PageSize); + Assert.True(log.Total >= 0); + Assert.NotNull(log.Entries); + } + + [Fact] + public async Task Log_UnknownPerson_IsNotFound() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Лог 404", Start, seed: 36); + + using var response = await client.GetAsync( + $"/api/schools/{school.Id}/people/nobody/log", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown-person", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task Log_PagesDoNotOverlap() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Лог страницы", Start, seed: 36); + var page = await GetPeopleAsync(client, school.Id); + var id = page.People[0].Id; + + var first = await GetLogAsync(client, school.Id, id, "page=1&pageSize=10&dir=asc"); + var second = await GetLogAsync(client, school.Id, id, "page=2&pageSize=10&dir=asc"); + Assert.Equal(first.Total, second.Total); + Assert.Empty(first.Entries.Select(row => (row.Time, row.Type, row.ThingDef)) + .Intersect(second.Entries.Select(row => (row.Time, row.Type, row.ThingDef)))); + } + + private static async Task GetPeopleAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?pageSize=10", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + Assert.NotEmpty(page.People); + return page; + } + + private static async Task GetLogAsync(HttpClient client, int schoolId, string personId, string query) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}/log?{query}", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + return page; + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record ProblemResponse(string? Code); + + private sealed record PeopleList(IReadOnlyList People); + + private sealed record PersonRow(string Id); + + private sealed record PersonLogResponse( + int Total, + int Page, + int PageSize, + IReadOnlyList Entries); + + private sealed record PersonLogEntry(DateTime Time, string Type, string Label, string? ThingDef); +} diff --git a/tests/HSchool.Simulation.Tests/PersonDayLogTests.cs b/tests/HSchool.Simulation.Tests/PersonDayLogTests.cs new file mode 100644 index 0000000..8eae1bd --- /dev/null +++ b/tests/HSchool.Simulation.Tests/PersonDayLogTests.cs @@ -0,0 +1,228 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation.Tests; + +public class PersonDayLogTests +{ + private static readonly DateTime TuesdayFive = new(2012, 4, 3, 5, 0, 0, DateTimeKind.Utc); + private static readonly DateTime TuesdayNoon = new(2012, 4, 3, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime TuesdayEvening = new(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc); + + [Fact] + public void TryStartAndFinish_WriteStartAndEndRows() + { + using var school = OpenCafeteria(TuesdayNoon); + Spawn(school, "a", hunger: 0.3f); + + Assert.True(school.TryStartAction("a", "EatLunch")); + Assert.Contains( + school.DayLog, + row => row.PersonId == "a" + && row.Type == PersonLogTypes.ActionStarted + && row.ThingDef == "EatLunch"); + + TickMinutes(school, 16); + + Assert.Contains( + school.DayLog, + row => row.PersonId == "a" + && row.Type == PersonLogTypes.ActionEnded + && row.ThingDef == "EatLunch"); + Assert.Equal( + "начал: Обед", + school.DayLog.First(row => row.Type == PersonLogTypes.ActionStarted).Caption(school.Catalog!, "ru")); + } + + [Fact] + public void AfterSix_YesterdaysRowsAreGone() + { + using var school = OpenCafeteria(TuesdayFive); + Spawn(school, "a", hunger: 0.3f); + Assert.True(school.TryStartAction("a", "EatLunch")); + Assert.NotEmpty(school.DayLog); + + TickMinutes(school, 61); + + Assert.True(school.Clock.Time >= new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc)); + Assert.Empty(school.DayLog); + } + + [Fact] + public void SkipEmpty_ClearsYesterdaysRows() + { + using var school = OpenEmpty(TuesdayEvening); + school.AppendDayLog(new PersonLogEvent("f0.c0", TuesdayEvening, PersonLogTypes.ActionStarted, "EatLunch")); + Assert.NotEmpty(school.DayLog); + + var result = school.TrySkipEmpty(); + + Assert.True(result.Succeeded); + Assert.Empty(school.DayLog); + } + + [Fact] + public void Pages_DoNotOverlap() + { + var time = TuesdayNoon; + var log = new List(); + for (var i = 0; i < 25; i++) + { + log.Add(new PersonLogEvent("p", time.AddMinutes(i), PersonLogTypes.ActionStarted, "EatLunch")); + } + + var first = PersonLogBrowser.Apply(log, "p", new PersonLogQuery(null, Descending: false, Page: 1, PageSize: 10), catalog: null, "ru"); + var second = PersonLogBrowser.Apply(log, "p", new PersonLogQuery(null, Descending: false, Page: 2, PageSize: 10), catalog: null, "ru"); + + Assert.Equal(25, first.Total); + Assert.Equal(10, first.Entries.Count); + Assert.Equal(10, second.Entries.Count); + Assert.Empty(first.Entries.Select(row => row.Time).Intersect(second.Entries.Select(row => row.Time))); + Assert.Equal(time, first.Entries[0].Time); + Assert.Equal(time.AddMinutes(10), second.Entries[0].Time); + } + + [Fact] + public void Search_FiltersByCaption() + { + var log = new[] + { + new PersonLogEvent("p", TuesdayNoon, PersonLogTypes.ActionStarted, "EatLunch"), + new PersonLogEvent("p", TuesdayNoon.AddMinutes(1), PersonLogTypes.ActionStarted, "UseToilet"), + }; + + var page = PersonLogBrowser.Apply(log, "p", new PersonLogQuery("toilet", Descending: false, Page: 1, PageSize: 20), catalog: null, "en"); + Assert.Equal(1, page.Total); + Assert.Equal("action-started", page.Entries[0].Type); + Assert.Equal("UseToilet", page.Entries[0].ThingDef); + } + + private static void TickMinutes(School school, int minutes) + { + for (var i = 0; i < minutes; i++) + { + school.Tick(0.2d, 5d); + } + } + + private static School OpenCafeteria(DateTime start) + { + var catalog = new CatalogLoader().Load( + [CatalogLoader.CorePackId], + [ + new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit", "abstract": true }"""), + new ContentDocument( + "core", + "defs/actions/eat.jsonc", + """ + { + "defName": "EatLunch", + "room": "Cafeteria", + "thing": "Chair", + "minutes": 15, + "need": "Hunger", + "needGain": 0.5, + "roles": ["student"] + } + """), + new ContentDocument( + "core", + "defs/needs/hunger.jsonc", + """{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 }"""), + new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""), + new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""), + new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""), + new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""), + new ContentDocument( + "core", + "defs/rooms/cafeteria.jsonc", + """ + { + "defName": "Cafeteria", + "slots": [{ "key": "seats", "thing": "Chair" }], + "travelMinutes": 1 + } + """), + new ContentDocument("core", "localizations/ru.jsonc", """{ "EatLunch": "Обед", "ActionStarted": "начал: {0}", "ActionEnded": "закончил: {0}" }"""), + ]); + var map = new MapLayout + { + Territory = new TerritoryNode { Id = "yard", Def = "Yard" }, + Buildings = [new BuildingNode { Id = "main", Def = "Main" }], + Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }], + Rooms = + [ + new RoomNode + { + Id = "cafe", + Def = "Cafeteria", + Building = "main", + Floor = "floor-1", + Slots = [new SlotFill { Key = "seats", Thing = "Chair", Count = 8 }], + }, + ], + Links = [new MapLink { A = "yard", B = "cafe" }], + }; + MapValidator.Validate(map, catalog); + return School.Create(1, "Столовая", start, catalog, map); + } + + private static void Spawn(School school, string id, float hunger) + { + school.World.Create( + new PersonIdentity(id, "f", false, TuesdayNoon, DummyName()), + new PersonRoles(true, false, false, null, null, null), + new PersonNeeds(new Dictionary(StringComparer.Ordinal) { ["Hunger"] = hunger }), + new Presence("cafe", 0f, "cafe", false, []), + PersonActivity.Idle); + } + + 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); + } + + private static PersonName DummyName() + { + var cases = new CaseTable + { + Nom = "А", + Gen = "А", + Dat = "А", + Acc = "А", + Ins = "А", + Pre = "А", + }; + return new PersonName("А", "А", "А", cases, cases, cases); + } +}