diff --git a/docs/phases/10-craft/58-worker-card-split.md b/docs/phases/10-craft/58-worker-card-split.md index e71de72..4536f07 100644 --- a/docs/phases/10-craft/58-worker-card-split.md +++ b/docs/phases/10-craft/58-worker-card-split.md @@ -12,22 +12,22 @@ DOM. Разрезать по существующим границам, не м ## Задачи -- [ ] `SchoolWorker` — `partial` (почта / тик / запись или эквивалент по швам). Один тип, +- [x] `SchoolWorker` — `partial` (почта / тик / запись или эквивалент по швам). Один тип, тот же `LongRunning`-поток, тот же mailbox. Без `lock` вокруг `School`/`World` -- [ ] `PersonCardReader`: публичный вход по-прежнему `Read(School, personId, locale)`; +- [x] `PersonCardReader`: публичный вход по-прежнему `Read(School, personId, locale)`; сборка вкладок — соседние `internal` helper или `partial`, не второй публичный API -- [ ] `personCard.ts`: вкладки в отдельных модулях; `personCardHost.ts` по-прежнему fetch -- [ ] Инверсия Game → типы из `HSchool.Server.Api` **не** входит: `using` можно оставить +- [x] `personCard.ts`: вкладки в отдельных модулях; `personCardHost.ts` по-прежнему fetch +- [x] Инверсия Game → типы из `HSchool.Server.Api` **не** входит: `using` можно оставить ## Тесты, без которых фаза не закрыта -- [ ] Карточка: нужды, одежда/ноша, связи — как сейчас (`PeopleApiTests`, +- [x] Карточка: нужды, одежда/ноша, связи — как сейчас (`PeopleApiTests`, `InventoryApiTests`, `Card_ConnectionsCarryOnlyThisPersonsOpinions`) -- [ ] `personCard.test.ts` (вкладки, портрет-кнопки, связи) проходит -- [ ] Пауза одной школы не останавливает другую (существующий хостовый факт) -- [ ] Дамп школы по-прежнему 200 на известный id (`Dump_ReturnsPeopleNodesAndTimetable` — +- [x] `personCard.test.ts` (вкладки, портрет-кнопки, связи) проходит +- [x] Пауза одной школы не останавливает другую (существующий хостовый факт) +- [x] Дамп школы по-прежнему 200 на известный id (`Dump_ReturnsPeopleNodesAndTimetable` — текущий оракул, ужесточение — фаза 59) -- [ ] `SchoolWorker` остаётся одним `internal sealed` типом (рефлексия или исходник: нет +- [x] `SchoolWorker` остаётся одним `internal sealed` типом (рефлексия или исходник: нет второго worker-класса с mailbox) ## Критерий готовности diff --git a/docs/phases/10-craft/README.md b/docs/phases/10-craft/README.md index 65f9959..46d87be 100644 --- a/docs/phases/10-craft/README.md +++ b/docs/phases/10-craft/README.md @@ -22,7 +22,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [58. SchoolWorker и карточка](58-worker-card-split.md) | 🔄 | partial / модули по швам, один поток | +| [58. SchoolWorker и карточка](58-worker-card-split.md) | ✅ | partial / модули по швам, один поток | **Этап C — дамп.** diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 9bf7cdb..cd76be0 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -1,18 +1,15 @@ -import type { - PersonCard, - PersonListItem, - PersonLogDir, - PersonLogPage, - PersonLogQuery, - PersonOpinionLink, - PersonRel, - PersonRole, -} from '../net/api.ts'; +import type { PersonCard, PersonListItem, PersonLogDir, PersonLogPage, PersonLogQuery, PersonRole } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts'; -import { formatGameTimeOfDay } from '../format/gameTime.ts'; -import { talkCircleText } from '../format/talkCircle.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; -import { clear, el } from './dom.ts'; +import { el } from './dom.ts'; +import { fillApparel } from './personCardApparel.ts'; +import { fillCarry } from './personCardCarry.ts'; +import { fillConnections } from './personCardConnections.ts'; +import { fillNow } from './personCardNow.ts'; +import { fillOverview } from './personCardOverview.ts'; +import { fillPortrait } from './personCardPortrait.ts'; + +export { nowActivityText } from './personCardNow.ts'; const ROLE_KEYS: Record = { student: 'peopleRoleStudent', @@ -72,28 +69,6 @@ export function placement(person: Pick 0 ? parts.join(' · ') : '—'; } -export function nowActivityText( - card: PersonCard, - away: boolean, - names: ReadonlyMap = new Map(), -): string { - const talk = talkCircleText( - card.id, - card.talkCircleMemberIds, - card.talkTopicId ?? '', - names, - ); - if (talk.length > 0) { - return talk; - } - - if (away && (card.activityLabel === null || card.activityLabel.length === 0)) { - return t('peopleAtHome'); - } - - return card.activityLabel ?? ''; -} - export function renderPersonCard( parent: HTMLElement, card: PersonCard, @@ -186,459 +161,6 @@ export function renderPersonCard( parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait); } -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); - - const family = section(t('peopleFamily')); - appendRelatives(family, t('peopleParents'), card.family.parents, onRelative); - appendRelatives(family, t('peopleChildren'), card.family.children, onRelative); - appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative); - appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative); - if (family.childElementCount > 1) { - parent.append(family); - } -} - -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 }), - ), - ); - 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.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 fillConnections( - parent: HTMLElement, - card: PersonCard, - onRelative: (id: string) => void, - options: RenderPersonCardOptions, -): void { - const connections = card.connections; - if (connections === null) { - return; - } - - if (card.orientation != null) { - const orientation = section(t('peopleOrientation')); - orientation.append(el('p', { class: 'people__orientation', text: card.orientation.label })); - parent.append(orientation); - } - - const family = section(t('peopleFamily')); - appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative); - appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative); - appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative); - appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative); - if (family.childElementCount > 1) { - parent.append(family); - } - - if (connections.pair != null) { - appendRelativesWithOpinion(parent, t('peopleConnectionsPair'), [connections.pair], onRelative); - } - - const crushes = connections.crushes ?? []; - const admirers = connections.admirers ?? []; - if (card.orientation != null || crushes.length > 0 || admirers.length > 0) { - appendOpinionLinks(parent, t('peopleConnectionsCrushes'), crushes, onRelative); - appendOpinionLinks(parent, t('peopleConnectionsAdmirers'), admirers, onRelative); - } - - appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative); - appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative); - - const search = el('input', { class: 'input people__input', type: 'search' }); - search.placeholder = t('peopleConnectionsSearch'); - search.value = options.connectionsQuery ?? ''; - const list = el('div', { class: 'people__connections-list' }); - const renderMatches = (): void => { - clear(list); - const query = search.value.trim().toLocaleLowerCase(); - const matches = connections.others.filter((row) => - query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query), - ); - if (matches.length === 0) { - list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') })); - return; - } - - appendOpinionLinks(list, '', matches, onRelative, false); - }; - - search.addEventListener('input', () => { - options.onConnectionsSearch?.(search.value); - renderMatches(); - }); - parent.append(search, list); - renderMatches(); -} - -function fillNow( - parent: HTMLElement, - card: PersonCard, - away: boolean, - log: PersonLogPage | null | undefined, - options: RenderPersonCardOptions, -): void { - const activity = nowActivityText(card, away, options.personNames); - 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 fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void { - parent.append(swarmStatusLine(options)); - - for (const variant of PORTRAIT_VARIANTS) { - parent.append(el('h4', { class: 'people__section-title', text: t(variant.titleKey) })); - parent.append(portraitPreview(card, options, variant)); - parent.append( - el('button', { - class: 'button button--small', - type: 'button', - text: options.portraitBusy === variant.kind ? t('peoplePortraitGenerating') : t(variant.generateKey), - disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), - onClick: () => options.onGeneratePortrait?.(variant.kind), - }), - ); - parent.append(portraitPromptView(variant.kind, options)); - } - - parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') })); - parent.append( - el( - 'label', - { class: 'people__field' }, - el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }), - el('textarea', { - class: 'input people__portrait-prompt-input', - rows: 3, - placeholder: t('peoplePortraitCustomPlaceholder'), - value: options.customPrompt ?? card.customPortraitPrompt ?? '', - disabled: (options.portraitBusy ?? null) !== null, - onInput: (event) => { - const target = event.target; - if (target instanceof HTMLTextAreaElement) { - options.onCustomPromptChange?.(target.value); - } - }, - }), - ), - ); - - parent.append(portraitCustomPreview(card, options)); - const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim(); - parent.append( - el('button', { - class: 'button button--small', - type: 'button', - dataset: { portraitAction: 'generate-custom' }, - text: - options.portraitBusy === 'custom' - ? t('peoplePortraitGenerating') - : card.hasCustom - ? t('peoplePortraitRegenerateCustom') - : t('peoplePortraitGenerateCustom'), - disabled: - (options.portraitBusy ?? null) !== null || - !portraitGenerateEnabled(options) || - customPrompt.length === 0, - onClick: () => options.onGeneratePortrait?.('custom'), - }), - ); - if (customPrompt.length > 0 || card.hasCustom) { - parent.append(portraitPromptView('custom', options)); - } - - if (options.swarmConfigured === false) { - parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); - } else if (options.swarmConfigured === true && options.swarmConnected === false) { - parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') })); - } - - const portraitError = options.portraitError; - if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) { - parent.append(el('p', { class: 'panel__error', text: portraitError })); - } - - const promptError = options.portraitPromptError; - if (promptError !== undefined && promptError !== null && promptError.length > 0) { - parent.append(el('p', { class: 'panel__error', text: promptError })); - } -} - -const PORTRAIT_VARIANTS: readonly { - readonly kind: Extract; - readonly titleKey: MessageKey; - readonly generateKey: MessageKey; - readonly cssClass: string; - readonly hasImage: (card: PersonCard) => boolean; -}[] = [ - { - kind: 'avatar', - titleKey: 'peoplePortraitAvatar', - generateKey: 'peoplePortraitGenerateAvatar', - cssClass: 'people__portrait--avatar', - hasImage: (card) => card.hasAvatar, - }, - { - kind: 'full', - titleKey: 'peoplePortraitFull', - generateKey: 'peoplePortraitGenerateFull', - cssClass: 'people__portrait--full', - hasImage: (card) => card.hasFullBody, - }, -]; - -function portraitPromptView( - kind: PortraitKind, - options: RenderPersonCardOptions, -): HTMLElement { - const open = options.portraitPromptOpen === kind; - const loading = options.portraitPromptLoading === kind; - const prompt = options.portraitPrompts?.[kind]; - const toggle = el('button', { - class: 'button button--small people__portrait-prompt-toggle', - type: 'button', - text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'), - disabled: (options.portraitPromptLoading ?? null) !== null && !loading, - onClick: () => options.onShowPortraitPrompt?.(kind), - }); - - if (!open && !loading) { - return el('div', { class: 'people__portrait-prompt-wrap' }, toggle); - } - - if (loading) { - return el( - 'div', - { class: 'people__portrait-prompt-wrap' }, - toggle, - el('p', { class: 'people__portrait-prompt-loading', text: t('peoplePortraitPromptLoading') }), - ); - } - - if (prompt === undefined) { - return el('div', { class: 'people__portrait-prompt-wrap' }, toggle); - } - - return el( - 'div', - { class: 'people__portrait-prompt-wrap' }, - toggle, - el('div', { class: 'people__portrait-prompt-view' }, - el('p', { class: 'people__portrait-prompt-meta', text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }) }), - el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }), - el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }), - el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }), - el('pre', { class: 'people__portrait-prompt-text people__portrait-prompt-text--muted', text: prompt.negative }), - ), - ); -} - -function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean { - if (options.swarmConfigured !== true) { - return false; - } - - return options.swarmConnected === true; -} - -function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement { - if (options.swarmConfigured === false) { - return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') }); - } - - if (options.swarmConnected === true) { - return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') }); - } - - if (options.swarmConnected === false) { - return el('p', { class: 'people__swarm-status people__swarm-status--bad', text: t('peoplePortraitSwarmDisconnected') }); - } - - return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') }); -} - -function portraitPreview( - card: PersonCard, - options: RenderPersonCardOptions, - variant: (typeof PORTRAIT_VARIANTS)[number], -): HTMLElement { - if (variant.hasImage(card) && options.schoolId !== null && options.schoolId !== undefined) { - return el('img', { - class: `people__portrait ${variant.cssClass}`, - alt: card.fullName, - src: portraitUrl(options.schoolId, card.id, variant.kind), - }); - } - - return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') }); -} - -function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement { - if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) { - return el('img', { - class: 'people__portrait people__portrait--custom', - alt: card.fullName, - src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision), - }); - } - - return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') }); -} - function cardMeta(card: PersonCard): string { const bits = [ roleLabels(card.roles), @@ -660,197 +182,3 @@ export function formatPersonPlace(kind: 'here' | 'walking' | 'away', nodeName = return t('presenceAt', { name: nodeName }); } - -function section(title: string): HTMLElement { - return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title })); -} - -/** - * Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran - * the card past the fold while three quarters of its width sat empty. - */ -function appendPairs( - parent: HTMLElement, - title: string, - rows: readonly { readonly label: string; readonly value: string }[], -): void { - if (rows.length === 0) { - return; - } - - const grid = el('dl', { class: 'people__pairs' }); - for (const row of rows) { - grid.append( - el( - 'div', - { class: 'people__pair' }, - el('dt', { text: row.label }), - el('dd', { text: row.value }), - ), - ); - } - - parent.append(section(title), grid); -} - -function formatMass(value: number): string { - return String(Math.round(value * 100) / 100); -} - -function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void { - if (values.length === 0) { - return; - } - - const tags = el('div', { class: 'people__tags' }); - for (const value of values) { - tags.append(el('span', { class: 'people__tag', text: value })); - } - - parent.append(section(title), tags); -} - -function appendNeeds( - parent: HTMLElement, - title: string, - rows: readonly { readonly label: string; readonly value: number }[], -): void { - if (rows.length === 0) { - return; - } - - const grid = el('div', { class: 'people__needs' }); - for (const row of rows) { - const share = Math.max(0, Math.min(1, row.value)); - 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); - - grid.append( - el( - 'div', - { class: 'people__need' }, - el('span', { class: 'people__need-label', text: row.label }), - el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }), - el('span', { class: 'people__need-track' }, fill), - ), - ); - } - - parent.append(section(title), grid); -} - -function appendRelatives( - parent: HTMLElement, - title: string, - relatives: readonly PersonRel[], - open: (id: string) => void, -): void { - if (relatives.length === 0) { - return; - } - - const list = el('span', { class: 'people__rel-list' }); - for (const relative of relatives) { - list.append( - el('button', { - class: 'people__link', - type: 'button', - text: relative.fullName, - onClick: () => open(relative.id), - }), - ); - } - - parent.append( - el( - 'div', - { class: 'people__rel' }, - el('span', { class: 'people__rel-title', text: title }), - list, - ), - ); -} - -function appendRelativesWithOpinion( - parent: HTMLElement, - title: string, - relatives: readonly PersonRel[], - open: (id: string) => void, -): void { - if (relatives.length === 0) { - return; - } - - const list = el('span', { class: 'people__rel-list' }); - for (const relative of relatives) { - const caption = - relative.opinion !== null && - relative.opinion !== undefined && - relative.opinionLabel !== null && - relative.opinionLabel !== undefined - ? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion }) - : relative.fullName; - list.append( - el('button', { - class: 'people__link', - type: 'button', - text: caption, - title: relative.fullName, - onClick: () => open(relative.id), - }), - ); - } - - parent.append( - el( - 'div', - { class: 'people__rel' }, - el('span', { class: 'people__rel-title', text: title }), - list, - ), - ); -} - -function appendOpinionLinks( - parent: HTMLElement, - title: string, - rows: readonly PersonOpinionLink[], - open: (id: string) => void, - titled = true, -): void { - if (rows.length === 0) { - return; - } - - const block = el('div', { class: 'people__connections-block' }); - if (titled && title.length > 0) { - block.append(el('h4', { class: 'people__section-title', text: title })); - } - - const list = el('dl', { class: 'people__pairs' }); - for (const row of rows) { - list.append( - el( - 'div', - { class: 'people__pair' }, - el( - 'dt', - {}, - el('button', { - class: 'people__link', - type: 'button', - text: row.fullName, - onClick: () => open(row.id), - }), - ), - el('dd', { - text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }), - }), - ), - ); - } - - block.append(list); - parent.append(block); -} diff --git a/src/HSchool.Client/src/ui/personCardApparel.ts b/src/HSchool.Client/src/ui/personCardApparel.ts new file mode 100644 index 0000000..27bb8c7 --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardApparel.ts @@ -0,0 +1,44 @@ +import type { PersonCard } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; + +export 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 }), + ), + ); + 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.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); +} diff --git a/src/HSchool.Client/src/ui/personCardCarry.ts b/src/HSchool.Client/src/ui/personCardCarry.ts new file mode 100644 index 0000000..73c01d8 --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardCarry.ts @@ -0,0 +1,40 @@ +import type { PersonCard } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; +import { formatMass } from './personCardDom.ts'; + +export 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); +} diff --git a/src/HSchool.Client/src/ui/personCardConnections.ts b/src/HSchool.Client/src/ui/personCardConnections.ts new file mode 100644 index 0000000..71a3173 --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardConnections.ts @@ -0,0 +1,71 @@ +import type { PersonCard } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { clear, el } from './dom.ts'; +import { appendOpinionLinks, appendRelativesWithOpinion, section } from './personCardDom.ts'; +import type { RenderPersonCardOptions } from './personCard.ts'; + +export function fillConnections( + parent: HTMLElement, + card: PersonCard, + onRelative: (id: string) => void, + options: RenderPersonCardOptions, +): void { + const connections = card.connections; + if (connections === null) { + return; + } + + if (card.orientation != null) { + const orientation = section(t('peopleOrientation')); + orientation.append(el('p', { class: 'people__orientation', text: card.orientation.label })); + parent.append(orientation); + } + + const family = section(t('peopleFamily')); + appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative); + appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative); + appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative); + appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative); + if (family.childElementCount > 1) { + parent.append(family); + } + + if (connections.pair != null) { + appendRelativesWithOpinion(parent, t('peopleConnectionsPair'), [connections.pair], onRelative); + } + + const crushes = connections.crushes ?? []; + const admirers = connections.admirers ?? []; + if (card.orientation != null || crushes.length > 0 || admirers.length > 0) { + appendOpinionLinks(parent, t('peopleConnectionsCrushes'), crushes, onRelative); + appendOpinionLinks(parent, t('peopleConnectionsAdmirers'), admirers, onRelative); + } + + appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative); + appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative); + + const search = el('input', { class: 'input people__input', type: 'search' }); + search.placeholder = t('peopleConnectionsSearch'); + search.value = options.connectionsQuery ?? ''; + const list = el('div', { class: 'people__connections-list' }); + const renderMatches = (): void => { + clear(list); + const query = search.value.trim().toLocaleLowerCase(); + const matches = connections.others.filter((row) => + query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query), + ); + if (matches.length === 0) { + list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') })); + return; + } + + appendOpinionLinks(list, '', matches, onRelative, false); + }; + + search.addEventListener('input', () => { + options.onConnectionsSearch?.(search.value); + renderMatches(); + }); + parent.append(search, list); + renderMatches(); +} diff --git a/src/HSchool.Client/src/ui/personCardDom.ts b/src/HSchool.Client/src/ui/personCardDom.ts new file mode 100644 index 0000000..d37f537 --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardDom.ts @@ -0,0 +1,197 @@ +import type { PersonOpinionLink, PersonRel } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; + +export function section(title: string): HTMLElement { + return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title })); +} + +/** + * Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran + * the card past the fold while three quarters of its width sat empty. + */ +export function appendPairs( + parent: HTMLElement, + title: string, + rows: readonly { readonly label: string; readonly value: string }[], +): void { + if (rows.length === 0) { + return; + } + + const grid = el('dl', { class: 'people__pairs' }); + for (const row of rows) { + grid.append( + el( + 'div', + { class: 'people__pair' }, + el('dt', { text: row.label }), + el('dd', { text: row.value }), + ), + ); + } + + parent.append(section(title), grid); +} + +export function formatMass(value: number): string { + return String(Math.round(value * 100) / 100); +} + +export function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void { + if (values.length === 0) { + return; + } + + const tags = el('div', { class: 'people__tags' }); + for (const value of values) { + tags.append(el('span', { class: 'people__tag', text: value })); + } + + parent.append(section(title), tags); +} + +export function appendNeeds( + parent: HTMLElement, + title: string, + rows: readonly { readonly label: string; readonly value: number }[], +): void { + if (rows.length === 0) { + return; + } + + const grid = el('div', { class: 'people__needs' }); + for (const row of rows) { + const share = Math.max(0, Math.min(1, row.value)); + 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); + + grid.append( + el( + 'div', + { class: 'people__need' }, + el('span', { class: 'people__need-label', text: row.label }), + el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }), + el('span', { class: 'people__need-track' }, fill), + ), + ); + } + + parent.append(section(title), grid); +} + +export function appendRelatives( + parent: HTMLElement, + title: string, + relatives: readonly PersonRel[], + open: (id: string) => void, +): void { + if (relatives.length === 0) { + return; + } + + const list = el('span', { class: 'people__rel-list' }); + for (const relative of relatives) { + list.append( + el('button', { + class: 'people__link', + type: 'button', + text: relative.fullName, + onClick: () => open(relative.id), + }), + ); + } + + parent.append( + el( + 'div', + { class: 'people__rel' }, + el('span', { class: 'people__rel-title', text: title }), + list, + ), + ); +} + +export function appendRelativesWithOpinion( + parent: HTMLElement, + title: string, + relatives: readonly PersonRel[], + open: (id: string) => void, +): void { + if (relatives.length === 0) { + return; + } + + const list = el('span', { class: 'people__rel-list' }); + for (const relative of relatives) { + const caption = + relative.opinion !== null && + relative.opinion !== undefined && + relative.opinionLabel !== null && + relative.opinionLabel !== undefined + ? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion }) + : relative.fullName; + list.append( + el('button', { + class: 'people__link', + type: 'button', + text: caption, + title: relative.fullName, + onClick: () => open(relative.id), + }), + ); + } + + parent.append( + el( + 'div', + { class: 'people__rel' }, + el('span', { class: 'people__rel-title', text: title }), + list, + ), + ); +} + +export function appendOpinionLinks( + parent: HTMLElement, + title: string, + rows: readonly PersonOpinionLink[], + open: (id: string) => void, + titled = true, +): void { + if (rows.length === 0) { + return; + } + + const block = el('div', { class: 'people__connections-block' }); + if (titled && title.length > 0) { + block.append(el('h4', { class: 'people__section-title', text: title })); + } + + const list = el('dl', { class: 'people__pairs' }); + for (const row of rows) { + list.append( + el( + 'div', + { class: 'people__pair' }, + el( + 'dt', + {}, + el('button', { + class: 'people__link', + type: 'button', + text: row.fullName, + onClick: () => open(row.id), + }), + ), + el('dd', { + text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }), + }), + ), + ); + } + + block.append(list); + parent.append(block); +} diff --git a/src/HSchool.Client/src/ui/personCardNow.ts b/src/HSchool.Client/src/ui/personCardNow.ts new file mode 100644 index 0000000..d2d405a --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardNow.ts @@ -0,0 +1,120 @@ +import type { PersonCard, PersonLogPage } from '../net/api.ts'; +import { formatGameTimeOfDay } from '../format/gameTime.ts'; +import { talkCircleText } from '../format/talkCircle.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; +import type { RenderPersonCardOptions } from './personCard.ts'; + +export function nowActivityText( + card: PersonCard, + away: boolean, + names: ReadonlyMap = new Map(), +): string { + const talk = talkCircleText( + card.id, + card.talkCircleMemberIds, + card.talkTopicId ?? '', + names, + ); + if (talk.length > 0) { + return talk; + } + + if (away && (card.activityLabel === null || card.activityLabel.length === 0)) { + return t('peopleAtHome'); + } + + return card.activityLabel ?? ''; +} + +export function fillNow( + parent: HTMLElement, + card: PersonCard, + away: boolean, + log: PersonLogPage | null | undefined, + options: RenderPersonCardOptions, +): void { + const activity = nowActivityText(card, away, options.personNames); + 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, + ), + ); +} diff --git a/src/HSchool.Client/src/ui/personCardOverview.ts b/src/HSchool.Client/src/ui/personCardOverview.ts new file mode 100644 index 0000000..e4b7d69 --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardOverview.ts @@ -0,0 +1,19 @@ +import type { PersonCard } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { appendNeeds, appendPairs, appendRelatives, appendTags, section } from './personCardDom.ts'; + +export 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); + + const family = section(t('peopleFamily')); + appendRelatives(family, t('peopleParents'), card.family.parents, onRelative); + appendRelatives(family, t('peopleChildren'), card.family.children, onRelative); + appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative); + appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative); + if (family.childElementCount > 1) { + parent.append(family); + } +} diff --git a/src/HSchool.Client/src/ui/personCardPortrait.ts b/src/HSchool.Client/src/ui/personCardPortrait.ts new file mode 100644 index 0000000..7de3aad --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardPortrait.ts @@ -0,0 +1,218 @@ +import type { PersonCard } from '../net/api.ts'; +import { portraitUrl, type PortraitKind } from '../net/api.ts'; +import { t, type MessageKey } from '../i18n/strings.ts'; +import { el } from './dom.ts'; +import type { RenderPersonCardOptions } from './personCard.ts'; + +const PORTRAIT_VARIANTS: readonly { + readonly kind: Extract; + readonly titleKey: MessageKey; + readonly generateKey: MessageKey; + readonly cssClass: string; + readonly hasImage: (card: PersonCard) => boolean; +}[] = [ + { + kind: 'avatar', + titleKey: 'peoplePortraitAvatar', + generateKey: 'peoplePortraitGenerateAvatar', + cssClass: 'people__portrait--avatar', + hasImage: (card) => card.hasAvatar, + }, + { + kind: 'full', + titleKey: 'peoplePortraitFull', + generateKey: 'peoplePortraitGenerateFull', + cssClass: 'people__portrait--full', + hasImage: (card) => card.hasFullBody, + }, +]; + +export function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void { + parent.append(swarmStatusLine(options)); + + for (const variant of PORTRAIT_VARIANTS) { + parent.append(el('h4', { class: 'people__section-title', text: t(variant.titleKey) })); + parent.append(portraitPreview(card, options, variant)); + parent.append( + el('button', { + class: 'button button--small', + type: 'button', + text: options.portraitBusy === variant.kind ? t('peoplePortraitGenerating') : t(variant.generateKey), + disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), + onClick: () => options.onGeneratePortrait?.(variant.kind), + }), + ); + parent.append(portraitPromptView(variant.kind, options)); + } + + parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') })); + parent.append( + el( + 'label', + { class: 'people__field' }, + el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }), + el('textarea', { + class: 'input people__portrait-prompt-input', + rows: 3, + placeholder: t('peoplePortraitCustomPlaceholder'), + value: options.customPrompt ?? card.customPortraitPrompt ?? '', + disabled: (options.portraitBusy ?? null) !== null, + onInput: (event) => { + const target = event.target; + if (target instanceof HTMLTextAreaElement) { + options.onCustomPromptChange?.(target.value); + } + }, + }), + ), + ); + + parent.append(portraitCustomPreview(card, options)); + const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim(); + parent.append( + el('button', { + class: 'button button--small', + type: 'button', + dataset: { portraitAction: 'generate-custom' }, + text: + options.portraitBusy === 'custom' + ? t('peoplePortraitGenerating') + : card.hasCustom + ? t('peoplePortraitRegenerateCustom') + : t('peoplePortraitGenerateCustom'), + disabled: + (options.portraitBusy ?? null) !== null || + !portraitGenerateEnabled(options) || + customPrompt.length === 0, + onClick: () => options.onGeneratePortrait?.('custom'), + }), + ); + if (customPrompt.length > 0 || card.hasCustom) { + parent.append(portraitPromptView('custom', options)); + } + + if (options.swarmConfigured === false) { + parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); + } else if (options.swarmConfigured === true && options.swarmConnected === false) { + parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') })); + } + + const portraitError = options.portraitError; + if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) { + parent.append(el('p', { class: 'panel__error', text: portraitError })); + } + + const promptError = options.portraitPromptError; + if (promptError !== undefined && promptError !== null && promptError.length > 0) { + parent.append(el('p', { class: 'panel__error', text: promptError })); + } +} + +function portraitPromptView(kind: PortraitKind, options: RenderPersonCardOptions): HTMLElement { + const open = options.portraitPromptOpen === kind; + const loading = options.portraitPromptLoading === kind; + const prompt = options.portraitPrompts?.[kind]; + const toggle = el('button', { + class: 'button button--small people__portrait-prompt-toggle', + type: 'button', + text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'), + disabled: (options.portraitPromptLoading ?? null) !== null && !loading, + onClick: () => options.onShowPortraitPrompt?.(kind), + }); + + if (!open && !loading) { + return el('div', { class: 'people__portrait-prompt-wrap' }, toggle); + } + + if (loading) { + return el( + 'div', + { class: 'people__portrait-prompt-wrap' }, + toggle, + el('p', { class: 'people__portrait-prompt-loading', text: t('peoplePortraitPromptLoading') }), + ); + } + + if (prompt === undefined) { + return el('div', { class: 'people__portrait-prompt-wrap' }, toggle); + } + + return el( + 'div', + { class: 'people__portrait-prompt-wrap' }, + toggle, + el( + 'div', + { class: 'people__portrait-prompt-view' }, + el('p', { + class: 'people__portrait-prompt-meta', + text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }), + }), + el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }), + el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }), + el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }), + el('pre', { + class: 'people__portrait-prompt-text people__portrait-prompt-text--muted', + text: prompt.negative, + }), + ), + ); +} + +function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean { + if (options.swarmConfigured !== true) { + return false; + } + + return options.swarmConnected === true; +} + +function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement { + if (options.swarmConfigured === false) { + return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') }); + } + + if (options.swarmConnected === true) { + return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') }); + } + + if (options.swarmConnected === false) { + return el('p', { + class: 'people__swarm-status people__swarm-status--bad', + text: t('peoplePortraitSwarmDisconnected'), + }); + } + + return el('p', { + class: 'people__swarm-status people__swarm-status--pending', + text: t('peoplePortraitSwarmChecking'), + }); +} + +function portraitPreview( + card: PersonCard, + options: RenderPersonCardOptions, + variant: (typeof PORTRAIT_VARIANTS)[number], +): HTMLElement { + if (variant.hasImage(card) && options.schoolId !== null && options.schoolId !== undefined) { + return el('img', { + class: `people__portrait ${variant.cssClass}`, + alt: card.fullName, + src: portraitUrl(options.schoolId, card.id, variant.kind), + }); + } + + return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') }); +} + +function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement { + if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) { + return el('img', { + class: 'people__portrait people__portrait--custom', + alt: card.fullName, + src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision), + }); + } + + return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') }); +} diff --git a/src/HSchool.Server/Game/PersonCardReader.Connections.cs b/src/HSchool.Server/Game/PersonCardReader.Connections.cs new file mode 100644 index 0000000..9487184 --- /dev/null +++ b/src/HSchool.Server/Game/PersonCardReader.Connections.cs @@ -0,0 +1,196 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Server.Api; + +namespace HSchool.Server.Game; + +internal static partial class PersonCardReader +{ + private static PersonConnectionsResponse Connections( + Roster roster, + Person person, + DefCatalog? catalog, + string locale) + { + var familyIds = OpinionStore.FamilyMemberIds(roster, person); + var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); + var family = FamilyWithOpinions(roster, person, people, catalog, locale); + var others = new List(); + foreach (var (targetId, value) in person.Opinions) + { + if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target)) + { + continue; + } + + others.Add(Link(target, value, catalog, locale)); + } + + others.Sort((left, right) => + { + var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion)); + return byAbs != 0 + ? byAbs + : string.Compare(left.FullName, right.FullName, StringComparison.Ordinal); + }); + + var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5; + var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray(); + var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray(); + var crushes = CrushesOf(person, people, catalog, locale); + var admirers = AdmirersOf(roster, person, people, catalog, locale); + var pair = PairOf(person, people, catalog, locale); + return new PersonConnectionsResponse(family, friends, enemies, others, crushes, admirers, pair); + } + + private static DefLabelResponse? OrientationOf(Person person, DefCatalog? catalog, string locale) + { + if (person.Orientation is null) + { + return null; + } + + var label = person.Orientation; + if (catalog is not null && catalog.Orientations.TryGetValue(person.Orientation, out var def)) + { + label = catalog.Label(locale, def); + } + + return new DefLabelResponse(person.Orientation, label); + } + + private static IReadOnlyList CrushesOf( + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + if (person.Bonds is null) + { + return []; + } + + var rows = new List(); + foreach (var id in person.Bonds.Crushes) + { + if (!people.TryGetValue(id, out var target)) + { + continue; + } + + var opinion = OpinionStore.Get(person, id) ?? 0; + rows.Add(Link(target, opinion, catalog, locale)); + } + + return rows; + } + + private static IReadOnlyList AdmirersOf( + Roster roster, + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + var rows = new List(); + foreach (var other in roster.People) + { + if (other.Id.Equals(person.Id, StringComparison.Ordinal) || other.Bonds is null) + { + continue; + } + + if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal)) + { + continue; + } + + var opinion = OpinionStore.Get(other, person.Id) ?? 0; + rows.Add(Link(other, opinion, catalog, locale)); + } + + return rows; + } + + private static PersonRelResponse? PairOf( + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + if (person.Bonds?.PartnerId is not { } partnerId || !people.TryGetValue(partnerId, out var partner)) + { + return null; + } + + int? opinion = null; + string? label = null; + if (person.Opinions.TryGetValue(partnerId, out var value)) + { + opinion = value; + label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value); + } + + return new PersonRelResponse(partner.Id, partner.Name.Full, partner.Female, opinion, label); + } + + private static PersonFamilyResponse FamilyWithOpinions( + Roster roster, + Person person, + IReadOnlyDictionary people, + DefCatalog? catalog, + string locale) + { + var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return new PersonFamilyResponse([], [], [], []); + } + + var inParents = InFamily(family.ParentIds, person.Id); + var inChildren = InFamily(family.ChildIds, person.Id); + return new PersonFamilyResponse( + inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [], + inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [], + inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [], + inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []); + } + + private static IReadOnlyList RelativesWithOpinions( + IReadOnlyList ids, + IReadOnlyDictionary people, + Person person, + string except, + DefCatalog? catalog, + string locale) + { + var rows = new List(); + foreach (var id in ids) + { + if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative)) + { + continue; + } + + int? opinion = null; + string? label = null; + if (person.Opinions.TryGetValue(id, out var value)) + { + opinion = value; + label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value); + } + + rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label)); + } + + return rows; + } + + private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) => + new( + target.Id, + target.Name.Full, + target.Female, + opinion, + catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion)); +} diff --git a/src/HSchool.Server/Game/PersonCardReader.Inventory.cs b/src/HSchool.Server/Game/PersonCardReader.Inventory.cs new file mode 100644 index 0000000..7118921 --- /dev/null +++ b/src/HSchool.Server/Game/PersonCardReader.Inventory.cs @@ -0,0 +1,109 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Server.Api; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +internal static partial class PersonCardReader +{ + private static IReadOnlyList Worn(Person person, DefCatalog? catalog, string locale) + { + var rows = new List(); + foreach (var item in person.Items) + { + if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)) + { + continue; + } + + IReadOnlyList layerIds = []; + IReadOnlyList fullyCovers = []; + if (catalog is not null && catalog.Things.TryGetValue(item.Def, out var def)) + { + layerIds = def.Layers; + fullyCovers = def.FullyCoversLayers; + } + + var layers = layerIds + .Select(layer => new DefLabelResponse(layer, catalog?.Text(locale, layer) ?? layer)) + .ToArray(); + rows.Add(new WornItemResponse( + item.Def, + ThingLabel(catalog, locale, item.Def), + item.Color, + ColorLabel(catalog, locale, item.Color), + layers, + item.Condition, + catalog is null + ? ApparelCondition.BandId(null, item.Condition) + : ApparelCondition.Label(catalog, locale, item.Condition), + fullyCovers)); + } + + return rows; + } + + private static IReadOnlyList Carried(Person person, DefCatalog? catalog, string locale) + { + var rows = new List(); + foreach (var item in person.Items) + { + if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)) + { + continue; + } + + var mass = catalog is not null && catalog.Things.TryGetValue(item.Def, out var def) ? def.Mass : 0f; + string? subjectLabel = null; + if (item.Subject is not null) + { + subjectLabel = catalog is not null && catalog.Subjects.TryGetValue(item.Subject, out var subject) + ? catalog.Label(locale, subject) + : item.Subject; + } + + rows.Add(new CarriedItemResponse( + item.Def, + ThingLabel(catalog, locale, item.Def), + item.Color, + ColorLabel(catalog, locale, item.Color), + item.Subject, + subjectLabel, + mass)); + } + + return rows; + } + + private static string ThingLabel(DefCatalog? catalog, string locale, string defName) + { + if (catalog is not null && catalog.Things.TryGetValue(defName, out var def)) + { + return catalog.Label(locale, def); + } + + return defName; + } + + private static string? ColorLabel(DefCatalog? catalog, string locale, string? color) => + color is null ? null : catalog?.Text(locale, color) ?? color; + + private static float Capacity( + Person person, + IReadOnlyDictionary? live, + DefCatalog? catalog) + { + if (catalog is null) + { + return 0f; + } + + if (live is not null) + { + return CarryMass.Capacity(catalog, live); + } + + return CarryMass.Capacity(catalog, person.Skills); + } +} diff --git a/src/HSchool.Server/Game/PersonCardReader.Overview.cs b/src/HSchool.Server/Game/PersonCardReader.Overview.cs new file mode 100644 index 0000000..20c4550 --- /dev/null +++ b/src/HSchool.Server/Game/PersonCardReader.Overview.cs @@ -0,0 +1,194 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Server.Api; + +namespace HSchool.Server.Game; + +internal static partial class PersonCardReader +{ + private static IReadOnlyList Body(Person person, DefCatalog? catalog, string locale) + { + var rows = new List(); + if (catalog is not null) + { + foreach (var def in catalog.BodyAttributes.Values) + { + if (def.Abstract) + { + continue; + } + + if (def.Kind == BodyAttributeKind.Number && person.Numbers.TryGetValue(def.DefName, out var number)) + { + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), number.ToString())); + } + else if (def.Kind == BodyAttributeKind.Choice && person.Choices.TryGetValue(def.DefName, out var choice)) + { + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), catalog.Text(locale, choice))); + } + } + } + else + { + foreach (var (id, number) in person.Numbers) + { + rows.Add(new LabeledStatResponse(id, id, number.ToString())); + } + + foreach (var (id, choice) in person.Choices) + { + rows.Add(new LabeledStatResponse(id, id, choice)); + } + } + + if (person.Choices.TryGetValue(BodyBuilds.Attribute, out var build) + && rows.TrueForAll(row => row.Id != BodyBuilds.Attribute)) + { + var label = catalog?.Text(locale, BodyBuilds.Attribute) ?? BodyBuilds.Attribute; + var value = catalog?.Text(locale, build) ?? build; + rows.Add(new LabeledStatResponse(BodyBuilds.Attribute, label, value)); + } + + return rows; + } + + private static IReadOnlyList Skills( + Person person, + IReadOnlyDictionary? live, + DefCatalog? catalog, + string locale) + { + if (catalog is null) + { + if (live is not null) + { + return live + .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, FormatSkill(pair.Value))) + .ToArray(); + } + + return person.Skills + .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString())) + .ToArray(); + } + + var rows = new List(); + foreach (var def in catalog.Skills.Values) + { + if (def.Abstract) + { + continue; + } + + if (live is not null) + { + if (!live.TryGetValue(def.DefName, out var liveValue)) + { + continue; + } + + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), FormatSkill(liveValue))); + continue; + } + + if (!person.Skills.TryGetValue(def.DefName, out var value)) + { + continue; + } + + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), value.ToString())); + } + + return rows; + } + + private static string FormatSkill(float value) => Math.Round(value, 2).ToString("0.##"); + + private static IReadOnlyList Traits(Person person, DefCatalog? catalog, string locale) + { + var rows = new List(person.Traits.Count); + foreach (var id in person.Traits) + { + var label = catalog is not null && catalog.Traits.TryGetValue(id, out var def) + ? catalog.Label(locale, def) + : id; + rows.Add(new DefLabelResponse(id, label)); + } + + return rows; + } + + private static IReadOnlyList Needs( + IReadOnlyDictionary values, + DefCatalog? catalog, + string locale) + { + if (catalog is null) + { + return values.Select(pair => new NeedStatResponse(pair.Key, pair.Key, pair.Value)).ToArray(); + } + + var rows = new List(); + foreach (var def in catalog.Needs.Values) + { + if (def.Abstract || !values.TryGetValue(def.DefName, out var value)) + { + continue; + } + + rows.Add(new NeedStatResponse(def.DefName, catalog.Label(locale, def), value)); + } + + return rows; + } + + private static PersonFamilyResponse Family(Roster roster, Person person) + { + var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return new PersonFamilyResponse([], [], [], []); + } + + var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); + var inParents = InFamily(family.ParentIds, person.Id); + var inChildren = InFamily(family.ChildIds, person.Id); + return new PersonFamilyResponse( + inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [], + inParents ? Relatives(family.ChildIds, people, except: person.Id) : [], + inChildren ? Relatives(family.ChildIds, people, except: person.Id) : [], + inParents ? Relatives(family.ParentIds, people, except: person.Id) : []); + } + + private static bool InFamily(IReadOnlyList ids, string id) + { + foreach (var candidate in ids) + { + if (candidate.Equals(id, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static IReadOnlyList Relatives( + IReadOnlyList ids, + IReadOnlyDictionary people, + string except) + { + var rows = new List(); + foreach (var id in ids) + { + if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative)) + { + continue; + } + + rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female)); + } + + return rows; + } +} diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 998e500..12b528d 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -9,7 +9,7 @@ namespace HSchool.Server.Game; /// /// Builds a person card on the school's worker thread so live need values come from the World. /// -internal static class PersonCardReader +internal static partial class PersonCardReader { private static readonly QueryDescription IdentityAndNeeds = new QueryDescription().WithAll(); @@ -136,478 +136,4 @@ internal static class PersonCardReader }); return found; } - - private static IReadOnlyList Body(Person person, DefCatalog? catalog, string locale) - { - var rows = new List(); - if (catalog is not null) - { - foreach (var def in catalog.BodyAttributes.Values) - { - if (def.Abstract) - { - continue; - } - - if (def.Kind == BodyAttributeKind.Number && person.Numbers.TryGetValue(def.DefName, out var number)) - { - rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), number.ToString())); - } - else if (def.Kind == BodyAttributeKind.Choice && person.Choices.TryGetValue(def.DefName, out var choice)) - { - rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), catalog.Text(locale, choice))); - } - } - } - else - { - foreach (var (id, number) in person.Numbers) - { - rows.Add(new LabeledStatResponse(id, id, number.ToString())); - } - - foreach (var (id, choice) in person.Choices) - { - rows.Add(new LabeledStatResponse(id, id, choice)); - } - } - - if (person.Choices.TryGetValue(BodyBuilds.Attribute, out var build) - && rows.TrueForAll(row => row.Id != BodyBuilds.Attribute)) - { - var label = catalog?.Text(locale, BodyBuilds.Attribute) ?? BodyBuilds.Attribute; - var value = catalog?.Text(locale, build) ?? build; - rows.Add(new LabeledStatResponse(BodyBuilds.Attribute, label, value)); - } - - return rows; - } - - private static IReadOnlyList Skills( - Person person, - IReadOnlyDictionary? live, - DefCatalog? catalog, - string locale) - { - if (catalog is null) - { - if (live is not null) - { - return live - .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, FormatSkill(pair.Value))) - .ToArray(); - } - - return person.Skills - .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString())) - .ToArray(); - } - - var rows = new List(); - foreach (var def in catalog.Skills.Values) - { - if (def.Abstract) - { - continue; - } - - if (live is not null) - { - if (!live.TryGetValue(def.DefName, out var liveValue)) - { - continue; - } - - rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), FormatSkill(liveValue))); - continue; - } - - if (!person.Skills.TryGetValue(def.DefName, out var value)) - { - continue; - } - - rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), value.ToString())); - } - - return rows; - } - - private static string FormatSkill(float value) => Math.Round(value, 2).ToString("0.##"); - - private static IReadOnlyList Traits(Person person, DefCatalog? catalog, string locale) - { - var rows = new List(person.Traits.Count); - foreach (var id in person.Traits) - { - var label = catalog is not null && catalog.Traits.TryGetValue(id, out var def) - ? catalog.Label(locale, def) - : id; - rows.Add(new DefLabelResponse(id, label)); - } - - return rows; - } - - private static IReadOnlyList Needs( - IReadOnlyDictionary values, - DefCatalog? catalog, - string locale) - { - if (catalog is null) - { - return values.Select(pair => new NeedStatResponse(pair.Key, pair.Key, pair.Value)).ToArray(); - } - - var rows = new List(); - foreach (var def in catalog.Needs.Values) - { - if (def.Abstract || !values.TryGetValue(def.DefName, out var value)) - { - continue; - } - - rows.Add(new NeedStatResponse(def.DefName, catalog.Label(locale, def), value)); - } - - return rows; - } - - private static IReadOnlyList Worn(Person person, DefCatalog? catalog, string locale) - { - var rows = new List(); - foreach (var item in person.Items) - { - if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)) - { - continue; - } - - IReadOnlyList layerIds = []; - IReadOnlyList fullyCovers = []; - if (catalog is not null && catalog.Things.TryGetValue(item.Def, out var def)) - { - layerIds = def.Layers; - fullyCovers = def.FullyCoversLayers; - } - - var layers = layerIds - .Select(layer => new DefLabelResponse(layer, catalog?.Text(locale, layer) ?? layer)) - .ToArray(); - rows.Add(new WornItemResponse( - item.Def, - ThingLabel(catalog, locale, item.Def), - item.Color, - ColorLabel(catalog, locale, item.Color), - layers, - item.Condition, - catalog is null - ? ApparelCondition.BandId(null, item.Condition) - : ApparelCondition.Label(catalog, locale, item.Condition), - fullyCovers)); - } - - return rows; - } - - private static IReadOnlyList Carried(Person person, DefCatalog? catalog, string locale) - { - var rows = new List(); - foreach (var item in person.Items) - { - if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)) - { - continue; - } - - var mass = catalog is not null && catalog.Things.TryGetValue(item.Def, out var def) ? def.Mass : 0f; - string? subjectLabel = null; - if (item.Subject is not null) - { - subjectLabel = catalog is not null && catalog.Subjects.TryGetValue(item.Subject, out var subject) - ? catalog.Label(locale, subject) - : item.Subject; - } - - rows.Add(new CarriedItemResponse( - item.Def, - ThingLabel(catalog, locale, item.Def), - item.Color, - ColorLabel(catalog, locale, item.Color), - item.Subject, - subjectLabel, - mass)); - } - - return rows; - } - - private static string ThingLabel(DefCatalog? catalog, string locale, string defName) - { - if (catalog is not null && catalog.Things.TryGetValue(defName, out var def)) - { - return catalog.Label(locale, def); - } - - return defName; - } - - private static string? ColorLabel(DefCatalog? catalog, string locale, string? color) => - color is null ? null : catalog?.Text(locale, color) ?? color; - - private static float Capacity( - Person person, - IReadOnlyDictionary? live, - DefCatalog? catalog) - { - if (catalog is null) - { - return 0f; - } - - if (live is not null) - { - return CarryMass.Capacity(catalog, live); - } - - return CarryMass.Capacity(catalog, person.Skills); - } - - private static PersonFamilyResponse Family(Roster roster, Person person) - { - var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); - if (family is null) - { - return new PersonFamilyResponse([], [], [], []); - } - - var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); - var inParents = InFamily(family.ParentIds, person.Id); - var inChildren = InFamily(family.ChildIds, person.Id); - return new PersonFamilyResponse( - inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [], - inParents ? Relatives(family.ChildIds, people, except: person.Id) : [], - inChildren ? Relatives(family.ChildIds, people, except: person.Id) : [], - inParents ? Relatives(family.ParentIds, people, except: person.Id) : []); - } - - private static bool InFamily(IReadOnlyList ids, string id) - { - foreach (var candidate in ids) - { - if (candidate.Equals(id, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static IReadOnlyList Relatives( - IReadOnlyList ids, - IReadOnlyDictionary people, - string except) - { - var rows = new List(); - foreach (var id in ids) - { - if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative)) - { - continue; - } - - rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female)); - } - - return rows; - } - - private static PersonConnectionsResponse Connections( - Roster roster, - Person person, - DefCatalog? catalog, - string locale) - { - var familyIds = OpinionStore.FamilyMemberIds(roster, person); - var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); - var family = FamilyWithOpinions(roster, person, people, catalog, locale); - var others = new List(); - foreach (var (targetId, value) in person.Opinions) - { - if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target)) - { - continue; - } - - others.Add(Link(target, value, catalog, locale)); - } - - others.Sort((left, right) => - { - var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion)); - return byAbs != 0 - ? byAbs - : string.Compare(left.FullName, right.FullName, StringComparison.Ordinal); - }); - - var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5; - var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray(); - var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray(); - var crushes = CrushesOf(person, people, catalog, locale); - var admirers = AdmirersOf(roster, person, people, catalog, locale); - var pair = PairOf(person, people, catalog, locale); - return new PersonConnectionsResponse(family, friends, enemies, others, crushes, admirers, pair); - } - - private static DefLabelResponse? OrientationOf(Person person, DefCatalog? catalog, string locale) - { - if (person.Orientation is null) - { - return null; - } - - var label = person.Orientation; - if (catalog is not null && catalog.Orientations.TryGetValue(person.Orientation, out var def)) - { - label = catalog.Label(locale, def); - } - - return new DefLabelResponse(person.Orientation, label); - } - - private static IReadOnlyList CrushesOf( - Person person, - IReadOnlyDictionary people, - DefCatalog? catalog, - string locale) - { - if (person.Bonds is null) - { - return []; - } - - var rows = new List(); - foreach (var id in person.Bonds.Crushes) - { - if (!people.TryGetValue(id, out var target)) - { - continue; - } - - var opinion = OpinionStore.Get(person, id) ?? 0; - rows.Add(Link(target, opinion, catalog, locale)); - } - - return rows; - } - - private static IReadOnlyList AdmirersOf( - Roster roster, - Person person, - IReadOnlyDictionary people, - DefCatalog? catalog, - string locale) - { - var rows = new List(); - foreach (var other in roster.People) - { - if (other.Id.Equals(person.Id, StringComparison.Ordinal) || other.Bonds is null) - { - continue; - } - - if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal)) - { - continue; - } - - var opinion = OpinionStore.Get(other, person.Id) ?? 0; - rows.Add(Link(other, opinion, catalog, locale)); - } - - return rows; - } - - private static PersonRelResponse? PairOf( - Person person, - IReadOnlyDictionary people, - DefCatalog? catalog, - string locale) - { - if (person.Bonds?.PartnerId is not { } partnerId || !people.TryGetValue(partnerId, out var partner)) - { - return null; - } - - int? opinion = null; - string? label = null; - if (person.Opinions.TryGetValue(partnerId, out var value)) - { - opinion = value; - label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value); - } - - return new PersonRelResponse(partner.Id, partner.Name.Full, partner.Female, opinion, label); - } - - private static PersonFamilyResponse FamilyWithOpinions( - Roster roster, - Person person, - IReadOnlyDictionary people, - DefCatalog? catalog, - string locale) - { - var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); - if (family is null) - { - return new PersonFamilyResponse([], [], [], []); - } - - var inParents = InFamily(family.ParentIds, person.Id); - var inChildren = InFamily(family.ChildIds, person.Id); - return new PersonFamilyResponse( - inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [], - inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [], - inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [], - inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []); - } - - private static IReadOnlyList RelativesWithOpinions( - IReadOnlyList ids, - IReadOnlyDictionary people, - Person person, - string except, - DefCatalog? catalog, - string locale) - { - var rows = new List(); - foreach (var id in ids) - { - if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative)) - { - continue; - } - - int? opinion = null; - string? label = null; - if (person.Opinions.TryGetValue(id, out var value)) - { - opinion = value; - label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value); - } - - rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label)); - } - - return rows; - } - - private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) => - new( - target.Id, - target.Name.Full, - target.Female, - opinion, - catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion)); } diff --git a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs new file mode 100644 index 0000000..4fb1c8c --- /dev/null +++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs @@ -0,0 +1,344 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; +using HSchool.Server.Api; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +internal sealed partial class SchoolWorker +{ + private void DrainMailbox() + { + var school = _school; + if (school is null) + { + while (_mailbox.Reader.TryRead(out var orphan)) + { + CompleteOrphan(orphan); + } + + return; + } + + var dirty = false; + + while (_mailbox.Reader.TryRead(out var command)) + { + // Every command here was triggered by a browser. One of them failing — an oversized + // snapshot, a client that vanished mid-send — must cost that command, not the school. + try + { + switch (command) + { + case WorkerCommand.Open open: + open.Client.OpenSchoolId = _id; + SendMapSnapshot(open.Client, school); + BroadcastClockTo(open.Client, school); + SendPresence(open.Client, school); + break; + + case WorkerCommand.Close close: + var leaving = _clients.Find(close.PlayerId); + if (leaving?.OpenSchoolId == _id) + { + leaving.OpenSchoolId = null; + } + + break; + + case WorkerCommand.SetRunning setRunning: + school.Clock.IsRunning = setRunning.Running; + dirty = true; + break; + + case WorkerCommand.SetSpeed setSpeed: + school.Clock.SpeedIndex = setSpeed.SpeedIndex; + dirty = true; + break; + + case WorkerCommand.SkipEmpty: + ApplySkip(school); + break; + + case WorkerCommand.Dump dump: + dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays)); + break; + + case WorkerCommand.GetPerson getPerson: + var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale); + getPerson.Result.TrySetResult( + card is null + ? new PersonCardResult(null, PersonLookupError.UnknownPerson) + : 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; + + case WorkerCommand.AssignSubject assign: + assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject)); + break; + + case WorkerCommand.UnassignSubject unassign: + unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); + break; + + case WorkerCommand.PinLesson pin: + pin.Result.TrySetResult( + ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period)); + break; + + case WorkerCommand.UnpinLesson unpin: + unpin.Result.TrySetResult( + ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period)); + break; + + case WorkerCommand.GetDressRules getRules: + getRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules)); + break; + + case WorkerCommand.SetDressRules setRules: + { + var next = school.DressRules; + if (setRules.PendingStudents is { } students) + { + next = next with { PendingStudents = students }; + } + + if (setRules.PendingStaff is { } staff) + { + next = next with { PendingStaff = staff }; + } + + school.DressRules = next; + setRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules)); + dirty = true; + break; + } + } + } + catch (Exception ex) + { + FailCommand(command, ex); + + _logger.LogError( + ex, + "Command {Command} failed for school {SchoolId}; the school keeps running.", + command.GetType().Name, + _id); + } + } + + if (dirty) + { + PublishSnapshot(); + BroadcastClock(); + + // Not written here: a client can send SetSpeed as fast as the socket allows, and each + // one used to be a synchronous file write on this thread. FlushSettings coalesces them. + _settingsDirty = true; + } + } + + private static void CompleteOrphan(WorkerCommand command) + { + switch (command) + { + case WorkerCommand.Dump dump: + dump.Result.TrySetResult(null); + break; + 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; + case WorkerCommand.AssignSubject assign: + assign.Result.TrySetResult(Staffing.UnknownSchool()); + break; + case WorkerCommand.UnassignSubject unassign: + unassign.Result.TrySetResult(Staffing.UnknownSchool()); + break; + case WorkerCommand.PinLesson pin: + pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); + break; + case WorkerCommand.UnpinLesson unpin: + unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); + break; + case WorkerCommand.GetDressRules getRules: + getRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool)); + break; + case WorkerCommand.SetDressRules setRules: + setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool)); + break; + } + } + + private static void FailCommand(WorkerCommand command, Exception exception) + { + switch (command) + { + case WorkerCommand.Dump dump: + dump.Result.TrySetException(exception); + break; + 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; + case WorkerCommand.AssignSubject assign: + assign.Result.TrySetException(exception); + break; + case WorkerCommand.UnassignSubject unassign: + unassign.Result.TrySetException(exception); + break; + case WorkerCommand.PinLesson pin: + pin.Result.TrySetException(exception); + break; + case WorkerCommand.UnpinLesson unpin: + unpin.Result.TrySetException(exception); + break; + case WorkerCommand.GetDressRules getRules: + getRules.Result.TrySetException(exception); + break; + case WorkerCommand.SetDressRules setRules: + setRules.Result.TrySetException(exception); + break; + } + } + + private StaffingOutcome ApplyHire(School school, string personId, string position) => + ApplyStaffingChange(school, (catalog, roster, pool) => + Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap)); + + private StaffingOutcome ApplyAssign(School school, string personId, string subject) => + ApplyStaffingChange(school, (catalog, roster, pool) => + Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); + + private StaffingOutcome ApplyUnassign(School school, string personId, string subject) => + ApplyStaffingChange(school, (catalog, roster, pool) => + Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); + + private StaffingOutcome ApplyStaffingChange( + School school, + Func apply) + { + if (school.Roster is null || school.Applicants is null || school.Catalog is null) + { + return Staffing.UnknownSchool(); + } + + var outcome = apply(school.Catalog, school.Roster, school.Applicants); + if (outcome.Error == StaffingError.None) + { + school.ApplyStaffing(outcome.Roster, outcome.Pool); + PersistPeople(); + RebuildTimetable(school); + } + + return outcome; + } + + private TimetableOutcome ApplyPin( + School school, + string classId, + string subject, + string roomId, + int day, + int period) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return TimetableOutcome.Fail(TimetableError.UnknownSchool); + } + + if (school.Roster.Classes.All(item => item.Id != classId)) + { + return TimetableOutcome.Fail(TimetableError.UnknownClass); + } + + if (!school.Catalog.Subjects.TryGetValue(subject, out var subjectDef) || subjectDef.Abstract) + { + return TimetableOutcome.Fail(TimetableError.UnknownSubject); + } + + if (school.Map.Rooms.All(room => room.Id != roomId)) + { + return TimetableOutcome.Fail(TimetableError.UnknownRoom); + } + + var teacherId = TeacherFor(school, classId, subject); + if (teacherId is null) + { + return TimetableOutcome.Fail(TimetableError.NoTeacher); + } + + var pin = new LessonPlacement(classId, subject, teacherId, roomId, day, period, Locked: true); + var locks = (school.Timetable?.Lessons.Where(lesson => lesson.Locked) ?? []) + .Where(lesson => lesson.ClassId != classId || lesson.Subject != subject + || lesson.Day != day || lesson.Period != period) + .Append(pin) + .ToArray(); + var table = SchoolTimetables.Build( + school.Catalog, + school.Map, + school.Roster, + locks, + _options.SchoolWeekDays); + if (!table.Lessons.Any(lesson => + lesson.Locked + && lesson.ClassId == classId + && lesson.Subject == subject + && lesson.RoomId == roomId + && lesson.Day == day + && lesson.Period == period)) + { + return TimetableOutcome.Fail(TimetableError.PinRejected); + } + + ApplyTable(school, table, broadcast: true); + return TimetableOutcome.Ok(table); + } + + private TimetableOutcome ApplyUnpin(School school, string classId, string subject, int day, int period) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return TimetableOutcome.Fail(TimetableError.UnknownSchool); + } + + var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? []; + var match = locks.FirstOrDefault(lesson => + lesson.ClassId == classId && lesson.Subject == subject && lesson.Day == day && lesson.Period == period); + if (match is null) + { + return TimetableOutcome.Fail(TimetableError.UnknownLesson); + } + + var next = SchoolTimetables.Build( + school.Catalog, + school.Map, + school.Roster, + locks.Where(lesson => lesson != match).ToArray(), + _options.SchoolWeekDays); + ApplyTable(school, next, broadcast: true); + return TimetableOutcome.Ok(next); + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.Persist.cs b/src/HSchool.Server/Game/SchoolWorker.Persist.cs new file mode 100644 index 0000000..f5ba79f --- /dev/null +++ b/src/HSchool.Server/Game/SchoolWorker.Persist.cs @@ -0,0 +1,219 @@ +using System.Diagnostics; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +internal sealed partial class SchoolWorker +{ + /// + /// Writes pause/speed changes, at most once per . + /// A single click still lands within that window; a burst collapses into one write. + /// + private void FlushSettings() + { + if (!_settingsDirty || Stopwatch.GetElapsedTime(_lastSettingsSave) < _options.MinSaveInterval) + { + return; + } + + Persist(); + _settingsDirty = false; + _lastSettingsSave = Stopwatch.GetTimestamp(); + } + + private void PublishSnapshot() + { + var school = _school; + if (school is null) + { + return; + } + + Volatile.Write( + ref _snapshot, + new SchoolState( + school.Id, + school.Name, + school.Clock.Time, + school.Clock.IsRunning, + (byte)school.Clock.SpeedIndex, + school.Catalog?.PackIds ?? _modIds ?? [], + school.PeopleSeed, + _owner)); + Volatile.Write(ref _rosterSnapshot, school.Roster); + Volatile.Write(ref _applicantSnapshot, school.Applicants); + Volatile.Write(ref _timetableSnapshot, school.Timetable); + Volatile.Write(ref _mapSnapshot, school.Map); + } + + /// + /// Writes the composition file. Not called from the 30-second clock save — the roster and + /// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick. + /// + private void PersistPeople() + { + var school = _school; + if (school?.Roster is null) + { + return; + } + + try + { + _store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster, school.Applicants)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not save people for school {SchoolId}; composition stays in memory.", _id); + } + } + + private void InstallTimetable(School school) + { + if (!_isNew) + { + var saved = _store.TryReadTimetable(_id); + if (saved is not null) + { + var restored = RestoreTimetable(school, saved); + school.SetTimetable(restored); + if (!saved.Lessons.SequenceEqual(restored.Lessons) + || !saved.Uncovered.SequenceEqual(restored.Uncovered)) + { + PersistTimetable(school); + } + + return; + } + } + + RebuildTimetable(school, broadcast: false); + } + + private Timetable RestoreTimetable(School school, Timetable saved) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return saved; + } + + var classIds = school.Roster.Classes.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + var peopleIds = school.Roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); + var valid = saved.Lessons + .Where(lesson => classIds.Contains(lesson.ClassId) && peopleIds.Contains(lesson.TeacherId)) + .ToArray(); + if (valid.Length == saved.Lessons.Count) + { + return saved; + } + + var locks = valid.Where(lesson => lesson.Locked).ToArray(); + return SchoolTimetables.Build( + school.Catalog, + school.Map, + school.Roster, + locks, + _options.SchoolWeekDays); + } + + private void RebuildTimetable(School school, bool broadcast = true) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return; + } + + var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? []; + ApplyTable( + school, + SchoolTimetables.Build(school.Catalog, school.Map, school.Roster, locks, _options.SchoolWeekDays), + broadcast); + } + + private static string? TeacherFor(School school, string classId, string subject) + { + var existing = school.Timetable?.Lessons.FirstOrDefault(lesson => + lesson.ClassId == classId && lesson.Subject == subject); + if (existing is not null) + { + return existing.TeacherId; + } + + return school.Roster?.People + .Where(person => person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal)) + .OrderBy(person => person.Id, StringComparer.Ordinal) + .Select(person => person.Id) + .FirstOrDefault(); + } + + private void ApplyTable(School school, Timetable table, bool broadcast) + { + school.SetTimetable(table); + PersistTimetable(school); + PublishSnapshot(); + if (broadcast) + { + BroadcastPresence(); + } + } + + /// + /// Writes the lesson table. Not called from the 30-second clock save — the table changes on + /// hire, unassign, pin and yearly intake, not every tick. + /// + private void PersistTimetable(School school) + { + if (school.Timetable is null) + { + return; + } + + try + { + _store.SaveTimetable(school.Id, school.Timetable); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not save the timetable for school {SchoolId}; it stays in memory.", _id); + } + } + + private void Persist() + { + var school = _school; + if (school is null) + { + return; + } + + // A full disk or a locked file must not end the school; the next save will try again. + try + { + _store.Save(new SchoolSave + { + Format = SchoolStore.CurrentFormat, + Id = school.Id, + Name = school.Name, + GameTime = school.Clock.Time, + Running = school.Clock.IsRunning, + SpeedIndex = school.Clock.SpeedIndex, + ModIds = school.Catalog?.PackIds, + Map = school.Map, + CountryId = school.CountryId, + ClimatePresetId = school.ClimatePresetId, + NativeLanguage = _nativeLanguage, + Presence = school.CapturePresence(), + DressRules = school.DressRules, + Owner = _owner, + PortraitSettings = _portraitSettings, + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not save school {SchoolId}; it keeps running unsaved.", _id); + } + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs new file mode 100644 index 0000000..c0f6ef6 --- /dev/null +++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs @@ -0,0 +1,491 @@ +using System.Diagnostics; +using HSchool.Content; +using HSchool.People; +using HSchool.Protocol; +using HSchool.Server.Net; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +internal sealed partial class SchoolWorker +{ + private void RunSync() + { + try + { + RunLoop(_stopping.Token); + } + catch (SchoolContentUnavailableException ex) + { + _logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id); + _started.TrySetException(ex); + ReportFailure(); + } + catch (Exception ex) + { + _logger.LogError(ex, "School {SchoolId} worker died.", _id); + _started.TrySetException(ex); + ReportFailure(); + } + } + + /// + /// Tells the supervisor this school is gone. Without it a dead worker stayed in the table and + /// the menu kept drawing its card with a frozen clock, as if the school were alive. + /// + private void ReportFailure() + { + if (_stopping.IsCancellationRequested) + { + // Already being torn down on purpose; the supervisor knows. + return; + } + + try + { + _onFailed(_id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not report the failure of school {SchoolId}.", _id); + } + } + + private void RunLoop(CancellationToken cancellationToken) + { + var packIds = _mods.NormalizePackIds(_modIds); + _logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds)); + foreach (var packId in packIds) + { + if (!_mods.PackExists(packId)) + { + throw new SchoolContentUnavailableException( + $"School {_id} needs mod '{packId}', but that folder is missing."); + } + } + + var catalog = _mods.LoadCatalog(packIds, _logger); + Volatile.Write(ref _catalogSnapshot, catalog); + var map = _mods.LoadMap(packIds, _savedMap); + Volatile.Write(ref _mapSnapshot, map); + try + { + MapValidator.Validate(map, catalog); + } + catch (MapValidationException ex) + { + throw new SchoolContentUnavailableException(ex.Message, ex); + } + + var school = _isNew + ? School.Create(_id, _name, _time, catalog, map) + : School.Load(_id, _name, _time, _running, _speedIndex, catalog, map); + + var peopleDirty = false; + try + { + peopleDirty = InstallPeople(school, catalog, map); + } + catch + { + school.Dispose(); + throw; + } + + _school = school; + school.DressRules = _savedDressRules ?? new SchoolDressRules(); + PublishSnapshot(); + + if (_isNew) + { + Persist(); + } + + if (peopleDirty) + { + PersistPeople(); + } + + _started.TrySetResult(); + + using var timer = new PeriodicTimer(_options.TickInterval); + var fixedDelta = _options.FixedDeltaTime; + var lastTimestamp = Stopwatch.GetTimestamp(); + var accumulator = 0d; + var lastSave = lastTimestamp; + var peopleChanged = false; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (!WaitForTick(timer, cancellationToken)) + { + break; + } + + DrainMailbox(); + + var now = Stopwatch.GetTimestamp(); + accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds; + lastTimestamp = now; + + var steps = 0; + peopleChanged = false; + while (accumulator >= fixedDelta && steps < MaxCatchUpSteps) + { + var stepStarted = Stopwatch.GetTimestamp(); + peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond); + _metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds); + + accumulator -= fixedDelta; + steps++; + } + + if (steps == MaxCatchUpSteps && accumulator >= fixedDelta) + { + _logger.LogWarning( + "School {SchoolId} is behind by {Backlog:F0} ms; dropping the backlog.", + _id, + accumulator * 1000); + accumulator = 0d; + } + + if (peopleChanged) + { + PersistPeople(); + if (school.TimetableDirty) + { + RebuildTimetable(school); + } + } + + if (steps > 0) + { + PublishSnapshot(); + BroadcastClock(); + MaybeBroadcastPresence(school); + } + + FlushSettings(); + + if (Stopwatch.GetElapsedTime(lastSave) >= _options.SaveInterval) + { + Persist(); + lastSave = Stopwatch.GetTimestamp(); + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + finally + { + DrainMailbox(); + + if (Volatile.Read(ref _persistOnStop)) + { + Persist(); + } + + school.Dispose(); + _school = null; + } + } + + /// + /// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine; + /// then runs here, not as a pool callback. + /// + private static bool WaitForTick(PeriodicTimer timer, CancellationToken cancellationToken) + { + try + { + return timer.WaitForNextTickAsync(cancellationToken).AsTask().GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + return false; + } + } + + private void MaybeBroadcastPresence(School school) + { + _presenceAge++; + var interval = Math.Max(1, _options.TickRate / 2); + if (_presenceAge < interval) + { + return; + } + + _presenceAge = 0; + BroadcastPresence(); + } + + private void ApplySkip(School school) + { + var result = school.TrySkipEmpty(); + if (!result.Succeeded) + { + return; + } + + if (result.PeopleChanged) + { + PersistPeople(); + if (school.TimetableDirty) + { + RebuildTimetable(school); + } + } + + PublishSnapshot(); + Persist(); + BroadcastClock(); + BroadcastPresence(); + _presenceAge = 0; + } + + private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) + { + var countryId = ResolveCountryId(catalog, _countryId); + if (countryId is null) + { + throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); + } + + if (!catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract) + { + throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); + } + + _climatePresetId = ResolveClimatePreset(country, _climatePresetId); + + var demand = SchoolDemand.From(catalog, map); + Roster roster; + ApplicantPool applicants; + int seed; + var generated = false; + string? native; + + if (_isNew) + { + if (_createSeed is not int createSeed) + { + throw new InvalidOperationException($"School {_id} was created without a people seed."); + } + + seed = createSeed; + native = ResolveNative(country, seed, _nativeLanguage, generating: true); + _nativeLanguage = native; + roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native); + applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); + generated = true; + } + else + { + var loaded = _store.TryReadPeople(_id); + if (loaded is null) + { + throw new SchoolContentUnavailableException( + $"School {_id} has no people file; the school was left unstarted."); + } + + seed = loaded.Seed; + native = ResolveNative(country, seed, _nativeLanguage, generating: false); + _nativeLanguage = native; + roster = loaded.ToRoster(); + if (loaded.Applicants is { Applicants.Count: > 0 }) + { + applicants = loaded.Applicants; + } + else + { + applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); + generated = true; + } + + if (DressGenerator.NeedsDressing(roster, applicants)) + { + roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time); + applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time); + generated = true; + } + + RequireKnownApparel(catalog, roster, applicants); + } + + if (OpinionGenerator.NeedsFamilyOpinions(roster)) + { + roster = OpinionGenerator.SeedFamily(catalog, roster); + generated = true; + } + + var assigned = OrientationGenerator.Assign(catalog, roster, seed); + generated |= !ReferenceEquals(assigned, roster); + roster = assigned; + var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed); + generated |= !ReferenceEquals(assignedPool, applicants); + applicants = assignedPool; + if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0) + { + generated = true; + } + + roster = LockerAssigner.Apply(catalog, map, roster); + + if (!RosterFit.Matches(roster, demand)) + { + throw new SchoolContentUnavailableException( + $"School {_id} roster does not match its map; the people file was left untouched."); + } + + school.InstallPeople(roster, seed, countryId, applicants, _nativeLanguage, _climatePresetId); + InstallTimetable(school); + school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick); + school.RestorePresence(_savedPresence); + return generated; + } + + private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants) + { + foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person))) + { + foreach (var item in person.Items) + { + if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract) + { + throw new SchoolContentUnavailableException( + $"School roster references unusable thing '{item.Def}'."); + } + } + } + } + + private static string? ResolveCountryId(DefCatalog catalog, string? requested) + { + if (string.IsNullOrWhiteSpace(requested)) + { + return null; + } + + return catalog.Countries.TryGetValue(requested, out var country) && !country.Abstract + ? requested + : null; + } + + private static string? ResolveClimatePreset(CountryDef country, string? requested) + { + if (!string.IsNullOrWhiteSpace(requested) && country.ClimatePresets.Contains(requested, StringComparer.Ordinal)) + { + return requested; + } + + return CountryClimate.Pick(country, schoolSeed: 0, rollIfOmitted: false); + } + + private static string? ResolveNative( + CountryDef country, + int schoolSeed, + string? requested, + bool generating) => + NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested)); + + private void BroadcastClock() + { + var school = _school; + if (school is null) + { + return; + } + + foreach (var client in _clients.All) + { + if (client.IsReady && client.OpenSchoolId == _id) + { + BroadcastClockTo(client, school); + } + } + } + + private void SendMapSnapshot(GameClient client, School school) + { + if (school.Catalog is null || school.Map is null) + { + return; + } + + var locale = ProtocolConstants.CatalogLocale(client.Locale); + var view = MapView.Build(school.Catalog, school.Map, locale); + var nodes = new MapSnapshotNode[view.Count]; + for (var i = 0; i < view.Count; i++) + { + var node = view[i]; + var items = new MapSnapshotItem[node.Items.Count]; + for (var item = 0; item < node.Items.Count; item++) + { + items[item] = new MapSnapshotItem(node.Items[item].Name, (byte)node.Items[item].Count); + } + + nodes[i] = new MapSnapshotNode( + (byte)node.Kind, + node.Id, + node.ParentId, + node.Name, + (ushort)node.PupilSlots, + items, + node.Positions); + } + + // Sized from the message, not from the inbound frame limit: a map the player enlarged in + // the create editor outgrows 8 KiB somewhere past sixty furnished rooms. + var message = new ServerMapSnapshotMessage(school.Id, nodes); + var frame = new byte[ProtocolCodec.MapSnapshotSize(message)]; + var length = ProtocolCodec.WriteMapSnapshot(frame, message); + client.TrySendReliable(frame.AsMemory(0, length)); + } + + private void BroadcastClockTo(GameClient client, School school) + { + var skip = school.PeekSkipEmpty(); + var frame = new byte[ProtocolCodec.MaxFrameSize]; + var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage( + school.Id, + new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(), + school.Clock.IsRunning, + (byte)school.Clock.SpeedIndex, + skip.Allowed, + skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0, + school.Weather.Tenths, + (byte)school.Weather.Precipitation)); + + client.TrySend(frame.AsMemory(0, length)); + } + + private void BroadcastPresence() + { + var school = _school; + if (school is null) + { + return; + } + + foreach (var client in _clients.All) + { + if (client.IsReady && client.OpenSchoolId == _id) + { + SendPresence(client, school); + } + } + } + + private void SendPresence(GameClient client, School school) + { + var locale = ProtocolConstants.CatalogLocale(client.Locale); + var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale); + var frame = new byte[ProtocolCodec.PresenceSize(message)]; + var length = ProtocolCodec.WritePresence(frame, message); + client.TrySendReliable(frame.AsMemory(0, length)); + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 65d9829..ca4fc6b 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -1,10 +1,7 @@ -using System.Diagnostics; using System.Threading.Channels; using HSchool.Content; using HSchool.People; -using HSchool.Protocol; using HSchool.Schedule; -using HSchool.Server.Api; using HSchool.Server.Net; using HSchool.Simulation; @@ -15,7 +12,7 @@ namespace HSchool.Server.Game; /// frozen catalog, that school's save file. Awaits are resolved with GetResult so /// stays on this thread instead of hopping back onto the pool. /// -internal sealed class SchoolWorker +internal sealed partial class SchoolWorker { private const int MaxCatchUpSteps = 5; @@ -175,1027 +172,4 @@ internal sealed class SchoolWorker } } } - - private void RunSync() - { - try - { - RunLoop(_stopping.Token); - } - catch (SchoolContentUnavailableException ex) - { - _logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id); - _started.TrySetException(ex); - ReportFailure(); - } - catch (Exception ex) - { - _logger.LogError(ex, "School {SchoolId} worker died.", _id); - _started.TrySetException(ex); - ReportFailure(); - } - } - - /// - /// Tells the supervisor this school is gone. Without it a dead worker stayed in the table and - /// the menu kept drawing its card with a frozen clock, as if the school were alive. - /// - private void ReportFailure() - { - if (_stopping.IsCancellationRequested) - { - // Already being torn down on purpose; the supervisor knows. - return; - } - - try - { - _onFailed(_id); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not report the failure of school {SchoolId}.", _id); - } - } - - private void RunLoop(CancellationToken cancellationToken) - { - var packIds = _mods.NormalizePackIds(_modIds); - _logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds)); - foreach (var packId in packIds) - { - if (!_mods.PackExists(packId)) - { - throw new SchoolContentUnavailableException( - $"School {_id} needs mod '{packId}', but that folder is missing."); - } - } - - var catalog = _mods.LoadCatalog(packIds, _logger); - Volatile.Write(ref _catalogSnapshot, catalog); - var map = _mods.LoadMap(packIds, _savedMap); - Volatile.Write(ref _mapSnapshot, map); - try - { - MapValidator.Validate(map, catalog); - } - catch (MapValidationException ex) - { - throw new SchoolContentUnavailableException(ex.Message, ex); - } - - var school = _isNew - ? School.Create(_id, _name, _time, catalog, map) - : School.Load(_id, _name, _time, _running, _speedIndex, catalog, map); - - var peopleDirty = false; - try - { - peopleDirty = InstallPeople(school, catalog, map); - } - catch - { - school.Dispose(); - throw; - } - - _school = school; - school.DressRules = _savedDressRules ?? new SchoolDressRules(); - PublishSnapshot(); - - if (_isNew) - { - Persist(); - } - - if (peopleDirty) - { - PersistPeople(); - } - - _started.TrySetResult(); - - using var timer = new PeriodicTimer(_options.TickInterval); - var fixedDelta = _options.FixedDeltaTime; - var lastTimestamp = Stopwatch.GetTimestamp(); - var accumulator = 0d; - var lastSave = lastTimestamp; - var peopleChanged = false; - - try - { - while (!cancellationToken.IsCancellationRequested) - { - if (!WaitForTick(timer, cancellationToken)) - { - break; - } - - DrainMailbox(); - - var now = Stopwatch.GetTimestamp(); - accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds; - lastTimestamp = now; - - var steps = 0; - peopleChanged = false; - while (accumulator >= fixedDelta && steps < MaxCatchUpSteps) - { - var stepStarted = Stopwatch.GetTimestamp(); - peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond); - _metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds); - - accumulator -= fixedDelta; - steps++; - } - - if (steps == MaxCatchUpSteps && accumulator >= fixedDelta) - { - _logger.LogWarning( - "School {SchoolId} is behind by {Backlog:F0} ms; dropping the backlog.", - _id, - accumulator * 1000); - accumulator = 0d; - } - - if (peopleChanged) - { - PersistPeople(); - if (school.TimetableDirty) - { - RebuildTimetable(school); - } - } - - if (steps > 0) - { - PublishSnapshot(); - BroadcastClock(); - MaybeBroadcastPresence(school); - } - - FlushSettings(); - - if (Stopwatch.GetElapsedTime(lastSave) >= _options.SaveInterval) - { - Persist(); - lastSave = Stopwatch.GetTimestamp(); - } - } - } - catch (OperationCanceledException) - { - // Normal shutdown. - } - finally - { - DrainMailbox(); - - if (Volatile.Read(ref _persistOnStop)) - { - Persist(); - } - - school.Dispose(); - _school = null; - } - } - - /// - /// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine; - /// then runs here, not as a pool callback. - /// - private static bool WaitForTick(PeriodicTimer timer, CancellationToken cancellationToken) - { - try - { - return timer.WaitForNextTickAsync(cancellationToken).AsTask().GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - return false; - } - } - - private void DrainMailbox() - { - var school = _school; - if (school is null) - { - while (_mailbox.Reader.TryRead(out var orphan)) - { - CompleteOrphan(orphan); - } - - return; - } - - var dirty = false; - - while (_mailbox.Reader.TryRead(out var command)) - { - // Every command here was triggered by a browser. One of them failing — an oversized - // snapshot, a client that vanished mid-send — must cost that command, not the school. - try - { - switch (command) - { - case WorkerCommand.Open open: - open.Client.OpenSchoolId = _id; - SendMapSnapshot(open.Client, school); - BroadcastClockTo(open.Client, school); - SendPresence(open.Client, school); - break; - - case WorkerCommand.Close close: - var leaving = _clients.Find(close.PlayerId); - if (leaving?.OpenSchoolId == _id) - { - leaving.OpenSchoolId = null; - } - - break; - - case WorkerCommand.SetRunning setRunning: - school.Clock.IsRunning = setRunning.Running; - dirty = true; - break; - - case WorkerCommand.SetSpeed setSpeed: - school.Clock.SpeedIndex = setSpeed.SpeedIndex; - dirty = true; - break; - - case WorkerCommand.SkipEmpty: - ApplySkip(school); - break; - - case WorkerCommand.Dump dump: - dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays)); - break; - - case WorkerCommand.GetPerson getPerson: - var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale); - getPerson.Result.TrySetResult( - card is null - ? new PersonCardResult(null, PersonLookupError.UnknownPerson) - : 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; - - case WorkerCommand.AssignSubject assign: - assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject)); - break; - - case WorkerCommand.UnassignSubject unassign: - unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); - break; - - case WorkerCommand.PinLesson pin: - pin.Result.TrySetResult( - ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period)); - break; - - case WorkerCommand.UnpinLesson unpin: - unpin.Result.TrySetResult( - ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period)); - break; - - case WorkerCommand.GetDressRules getRules: - getRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules)); - break; - - case WorkerCommand.SetDressRules setRules: - { - var next = school.DressRules; - if (setRules.PendingStudents is { } students) - { - next = next with { PendingStudents = students }; - } - - if (setRules.PendingStaff is { } staff) - { - next = next with { PendingStaff = staff }; - } - - school.DressRules = next; - setRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules)); - dirty = true; - break; - } - } - } - catch (Exception ex) - { - FailCommand(command, ex); - - _logger.LogError( - ex, - "Command {Command} failed for school {SchoolId}; the school keeps running.", - command.GetType().Name, - _id); - } - } - - if (dirty) - { - PublishSnapshot(); - BroadcastClock(); - - // Not written here: a client can send SetSpeed as fast as the socket allows, and each - // one used to be a synchronous file write on this thread. FlushSettings coalesces them. - _settingsDirty = true; - } - } - - private static void CompleteOrphan(WorkerCommand command) - { - switch (command) - { - case WorkerCommand.Dump dump: - dump.Result.TrySetResult(null); - break; - 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; - case WorkerCommand.AssignSubject assign: - assign.Result.TrySetResult(Staffing.UnknownSchool()); - break; - case WorkerCommand.UnassignSubject unassign: - unassign.Result.TrySetResult(Staffing.UnknownSchool()); - break; - case WorkerCommand.PinLesson pin: - pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); - break; - case WorkerCommand.UnpinLesson unpin: - unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); - break; - case WorkerCommand.GetDressRules getRules: - getRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool)); - break; - case WorkerCommand.SetDressRules setRules: - setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool)); - break; - } - } - - private static void FailCommand(WorkerCommand command, Exception exception) - { - switch (command) - { - case WorkerCommand.Dump dump: - dump.Result.TrySetException(exception); - break; - 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; - case WorkerCommand.AssignSubject assign: - assign.Result.TrySetException(exception); - break; - case WorkerCommand.UnassignSubject unassign: - unassign.Result.TrySetException(exception); - break; - case WorkerCommand.PinLesson pin: - pin.Result.TrySetException(exception); - break; - case WorkerCommand.UnpinLesson unpin: - unpin.Result.TrySetException(exception); - break; - case WorkerCommand.GetDressRules getRules: - getRules.Result.TrySetException(exception); - break; - case WorkerCommand.SetDressRules setRules: - setRules.Result.TrySetException(exception); - break; - } - } - - private StaffingOutcome ApplyHire(School school, string personId, string position) => - ApplyStaffingChange(school, (catalog, roster, pool) => - Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap)); - - private StaffingOutcome ApplyAssign(School school, string personId, string subject) => - ApplyStaffingChange(school, (catalog, roster, pool) => - Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); - - private StaffingOutcome ApplyUnassign(School school, string personId, string subject) => - ApplyStaffingChange(school, (catalog, roster, pool) => - Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); - - private StaffingOutcome ApplyStaffingChange( - School school, - Func apply) - { - if (school.Roster is null || school.Applicants is null || school.Catalog is null) - { - return Staffing.UnknownSchool(); - } - - var outcome = apply(school.Catalog, school.Roster, school.Applicants); - if (outcome.Error == StaffingError.None) - { - school.ApplyStaffing(outcome.Roster, outcome.Pool); - PersistPeople(); - RebuildTimetable(school); - } - - return outcome; - } - - /// - /// Writes pause/speed changes, at most once per . - /// A single click still lands within that window; a burst collapses into one write. - /// - private void FlushSettings() - { - if (!_settingsDirty || Stopwatch.GetElapsedTime(_lastSettingsSave) < _options.MinSaveInterval) - { - return; - } - - Persist(); - _settingsDirty = false; - _lastSettingsSave = Stopwatch.GetTimestamp(); - } - - private void PublishSnapshot() - { - var school = _school; - if (school is null) - { - return; - } - - Volatile.Write( - ref _snapshot, - new SchoolState( - school.Id, - school.Name, - school.Clock.Time, - school.Clock.IsRunning, - (byte)school.Clock.SpeedIndex, - school.Catalog?.PackIds ?? _modIds ?? [], - school.PeopleSeed, - _owner)); - Volatile.Write(ref _rosterSnapshot, school.Roster); - Volatile.Write(ref _applicantSnapshot, school.Applicants); - Volatile.Write(ref _timetableSnapshot, school.Timetable); - Volatile.Write(ref _mapSnapshot, school.Map); - } - - /// - /// Writes the composition file. Not called from the 30-second clock save — the roster and - /// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick. - /// - private void PersistPeople() - { - var school = _school; - if (school?.Roster is null) - { - return; - } - - try - { - _store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster, school.Applicants)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not save people for school {SchoolId}; composition stays in memory.", _id); - } - } - - private void InstallTimetable(School school) - { - if (!_isNew) - { - var saved = _store.TryReadTimetable(_id); - if (saved is not null) - { - var restored = RestoreTimetable(school, saved); - school.SetTimetable(restored); - if (!saved.Lessons.SequenceEqual(restored.Lessons) - || !saved.Uncovered.SequenceEqual(restored.Uncovered)) - { - PersistTimetable(school); - } - - return; - } - } - - RebuildTimetable(school, broadcast: false); - } - - private Timetable RestoreTimetable(School school, Timetable saved) - { - if (school.Catalog is null || school.Map is null || school.Roster is null) - { - return saved; - } - - var classIds = school.Roster.Classes.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); - var peopleIds = school.Roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); - var valid = saved.Lessons - .Where(lesson => classIds.Contains(lesson.ClassId) && peopleIds.Contains(lesson.TeacherId)) - .ToArray(); - if (valid.Length == saved.Lessons.Count) - { - return saved; - } - - var locks = valid.Where(lesson => lesson.Locked).ToArray(); - return SchoolTimetables.Build( - school.Catalog, - school.Map, - school.Roster, - locks, - _options.SchoolWeekDays); - } - - private void RebuildTimetable(School school, bool broadcast = true) - { - if (school.Catalog is null || school.Map is null || school.Roster is null) - { - return; - } - - var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? []; - ApplyTable( - school, - SchoolTimetables.Build(school.Catalog, school.Map, school.Roster, locks, _options.SchoolWeekDays), - broadcast); - } - - private TimetableOutcome ApplyPin( - School school, - string classId, - string subject, - string roomId, - int day, - int period) - { - if (school.Catalog is null || school.Map is null || school.Roster is null) - { - return TimetableOutcome.Fail(TimetableError.UnknownSchool); - } - - if (school.Roster.Classes.All(item => item.Id != classId)) - { - return TimetableOutcome.Fail(TimetableError.UnknownClass); - } - - if (!school.Catalog.Subjects.TryGetValue(subject, out var subjectDef) || subjectDef.Abstract) - { - return TimetableOutcome.Fail(TimetableError.UnknownSubject); - } - - if (school.Map.Rooms.All(room => room.Id != roomId)) - { - return TimetableOutcome.Fail(TimetableError.UnknownRoom); - } - - var teacherId = TeacherFor(school, classId, subject); - if (teacherId is null) - { - return TimetableOutcome.Fail(TimetableError.NoTeacher); - } - - var pin = new LessonPlacement(classId, subject, teacherId, roomId, day, period, Locked: true); - var locks = (school.Timetable?.Lessons.Where(lesson => lesson.Locked) ?? []) - .Where(lesson => lesson.ClassId != classId || lesson.Subject != subject - || lesson.Day != day || lesson.Period != period) - .Append(pin) - .ToArray(); - var table = SchoolTimetables.Build( - school.Catalog, - school.Map, - school.Roster, - locks, - _options.SchoolWeekDays); - if (!table.Lessons.Any(lesson => - lesson.Locked - && lesson.ClassId == classId - && lesson.Subject == subject - && lesson.RoomId == roomId - && lesson.Day == day - && lesson.Period == period)) - { - return TimetableOutcome.Fail(TimetableError.PinRejected); - } - - ApplyTable(school, table, broadcast: true); - return TimetableOutcome.Ok(table); - } - - private TimetableOutcome ApplyUnpin(School school, string classId, string subject, int day, int period) - { - if (school.Catalog is null || school.Map is null || school.Roster is null) - { - return TimetableOutcome.Fail(TimetableError.UnknownSchool); - } - - var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? []; - var match = locks.FirstOrDefault(lesson => - lesson.ClassId == classId && lesson.Subject == subject && lesson.Day == day && lesson.Period == period); - if (match is null) - { - return TimetableOutcome.Fail(TimetableError.UnknownLesson); - } - - var next = SchoolTimetables.Build( - school.Catalog, - school.Map, - school.Roster, - locks.Where(lesson => lesson != match).ToArray(), - _options.SchoolWeekDays); - ApplyTable(school, next, broadcast: true); - return TimetableOutcome.Ok(next); - } - - private static string? TeacherFor(School school, string classId, string subject) - { - var existing = school.Timetable?.Lessons.FirstOrDefault(lesson => - lesson.ClassId == classId && lesson.Subject == subject); - if (existing is not null) - { - return existing.TeacherId; - } - - return school.Roster?.People - .Where(person => person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal)) - .OrderBy(person => person.Id, StringComparer.Ordinal) - .Select(person => person.Id) - .FirstOrDefault(); - } - - private void ApplyTable(School school, Timetable table, bool broadcast) - { - school.SetTimetable(table); - PersistTimetable(school); - PublishSnapshot(); - if (broadcast) - { - BroadcastPresence(); - } - } - - /// - /// Writes the lesson table. Not called from the 30-second clock save — the table changes on - /// hire, unassign, pin and yearly intake, not every tick. - /// - private void PersistTimetable(School school) - { - if (school.Timetable is null) - { - return; - } - - try - { - _store.SaveTimetable(school.Id, school.Timetable); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not save the timetable for school {SchoolId}; it stays in memory.", _id); - } - } - - private void MaybeBroadcastPresence(School school) - { - _presenceAge++; - var interval = Math.Max(1, _options.TickRate / 2); - if (_presenceAge < interval) - { - return; - } - - _presenceAge = 0; - BroadcastPresence(); - } - - private void ApplySkip(School school) - { - var result = school.TrySkipEmpty(); - if (!result.Succeeded) - { - return; - } - - if (result.PeopleChanged) - { - PersistPeople(); - if (school.TimetableDirty) - { - RebuildTimetable(school); - } - } - - PublishSnapshot(); - Persist(); - BroadcastClock(); - BroadcastPresence(); - _presenceAge = 0; - } - - private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) - { - var countryId = ResolveCountryId(catalog, _countryId); - if (countryId is null) - { - throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); - } - - if (!catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract) - { - throw new SchoolContentUnavailableException($"School {_id} has no country in its catalog."); - } - - _climatePresetId = ResolveClimatePreset(country, _climatePresetId); - - var demand = SchoolDemand.From(catalog, map); - Roster roster; - ApplicantPool applicants; - int seed; - var generated = false; - string? native; - - if (_isNew) - { - if (_createSeed is not int createSeed) - { - throw new InvalidOperationException($"School {_id} was created without a people seed."); - } - - seed = createSeed; - native = ResolveNative(country, seed, _nativeLanguage, generating: true); - _nativeLanguage = native; - roster = RosterGenerator.Generate(catalog, map, seed, countryId, school.Clock.Time, native); - applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); - generated = true; - } - else - { - var loaded = _store.TryReadPeople(_id); - if (loaded is null) - { - throw new SchoolContentUnavailableException( - $"School {_id} has no people file; the school was left unstarted."); - } - - seed = loaded.Seed; - native = ResolveNative(country, seed, _nativeLanguage, generating: false); - _nativeLanguage = native; - roster = loaded.ToRoster(); - if (loaded.Applicants is { Applicants.Count: > 0 }) - { - applicants = loaded.Applicants; - } - else - { - applicants = ApplicantPool.Create(catalog, roster, seed, countryId, school.Clock.Time, native); - generated = true; - } - - if (DressGenerator.NeedsDressing(roster, applicants)) - { - roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time); - applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time); - generated = true; - } - - RequireKnownApparel(catalog, roster, applicants); - } - - if (OpinionGenerator.NeedsFamilyOpinions(roster)) - { - roster = OpinionGenerator.SeedFamily(catalog, roster); - generated = true; - } - - var assigned = OrientationGenerator.Assign(catalog, roster, seed); - generated |= !ReferenceEquals(assigned, roster); - roster = assigned; - var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed); - generated |= !ReferenceEquals(assignedPool, applicants); - applicants = assignedPool; - if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0) - { - generated = true; - } - - roster = LockerAssigner.Apply(catalog, map, roster); - - if (!RosterFit.Matches(roster, demand)) - { - throw new SchoolContentUnavailableException( - $"School {_id} roster does not match its map; the people file was left untouched."); - } - - school.InstallPeople(roster, seed, countryId, applicants, _nativeLanguage, _climatePresetId); - InstallTimetable(school); - school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick); - school.RestorePresence(_savedPresence); - return generated; - } - - private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants) - { - foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person))) - { - foreach (var item in person.Items) - { - if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract) - { - throw new SchoolContentUnavailableException( - $"School roster references unusable thing '{item.Def}'."); - } - } - } - } - - private static string? ResolveCountryId(DefCatalog catalog, string? requested) - { - if (string.IsNullOrWhiteSpace(requested)) - { - return null; - } - - return catalog.Countries.TryGetValue(requested, out var country) && !country.Abstract - ? requested - : null; - } - - private static string? ResolveClimatePreset(CountryDef country, string? requested) - { - if (!string.IsNullOrWhiteSpace(requested) && country.ClimatePresets.Contains(requested, StringComparer.Ordinal)) - { - return requested; - } - - return CountryClimate.Pick(country, schoolSeed: 0, rollIfOmitted: false); - } - - private static string? ResolveNative( - CountryDef country, - int schoolSeed, - string? requested, - bool generating) => - NativeLanguages.Pick(country.Names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested)); - - private void Persist() - { - var school = _school; - if (school is null) - { - return; - } - - // A full disk or a locked file must not end the school; the next save will try again. - try - { - _store.Save(new SchoolSave - { - Format = SchoolStore.CurrentFormat, - Id = school.Id, - Name = school.Name, - GameTime = school.Clock.Time, - Running = school.Clock.IsRunning, - SpeedIndex = school.Clock.SpeedIndex, - ModIds = school.Catalog?.PackIds, - Map = school.Map, - CountryId = school.CountryId, - ClimatePresetId = school.ClimatePresetId, - NativeLanguage = _nativeLanguage, - Presence = school.CapturePresence(), - DressRules = school.DressRules, - Owner = _owner, - PortraitSettings = _portraitSettings, - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not save school {SchoolId}; it keeps running unsaved.", _id); - } - } - - private void BroadcastClock() - { - var school = _school; - if (school is null) - { - return; - } - - foreach (var client in _clients.All) - { - if (client.IsReady && client.OpenSchoolId == _id) - { - BroadcastClockTo(client, school); - } - } - } - - private void SendMapSnapshot(GameClient client, School school) - { - if (school.Catalog is null || school.Map is null) - { - return; - } - - var locale = ProtocolConstants.CatalogLocale(client.Locale); - var view = MapView.Build(school.Catalog, school.Map, locale); - var nodes = new MapSnapshotNode[view.Count]; - for (var i = 0; i < view.Count; i++) - { - var node = view[i]; - var items = new MapSnapshotItem[node.Items.Count]; - for (var item = 0; item < node.Items.Count; item++) - { - items[item] = new MapSnapshotItem(node.Items[item].Name, (byte)node.Items[item].Count); - } - - nodes[i] = new MapSnapshotNode( - (byte)node.Kind, - node.Id, - node.ParentId, - node.Name, - (ushort)node.PupilSlots, - items, - node.Positions); - } - - // Sized from the message, not from the inbound frame limit: a map the player enlarged in - // the create editor outgrows 8 KiB somewhere past sixty furnished rooms. - var message = new ServerMapSnapshotMessage(school.Id, nodes); - var frame = new byte[ProtocolCodec.MapSnapshotSize(message)]; - var length = ProtocolCodec.WriteMapSnapshot(frame, message); - client.TrySendReliable(frame.AsMemory(0, length)); - } - - private void BroadcastClockTo(GameClient client, School school) - { - var skip = school.PeekSkipEmpty(); - var frame = new byte[ProtocolCodec.MaxFrameSize]; - var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage( - school.Id, - new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(), - school.Clock.IsRunning, - (byte)school.Clock.SpeedIndex, - skip.Allowed, - skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0, - school.Weather.Tenths, - (byte)school.Weather.Precipitation)); - - client.TrySend(frame.AsMemory(0, length)); - } - - private void BroadcastPresence() - { - var school = _school; - if (school is null) - { - return; - } - - foreach (var client in _clients.All) - { - if (client.IsReady && client.OpenSchoolId == _id) - { - SendPresence(client, school); - } - } - } - - private void SendPresence(GameClient client, School school) - { - var locale = ProtocolConstants.CatalogLocale(client.Locale); - var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale); - var frame = new byte[ProtocolCodec.PresenceSize(message)]; - var length = ProtocolCodec.WritePresence(frame, message); - client.TrySendReliable(frame.AsMemory(0, length)); - } } diff --git a/tests/HSchool.Server.Tests/SchoolWorkerShapeTests.cs b/tests/HSchool.Server.Tests/SchoolWorkerShapeTests.cs new file mode 100644 index 0000000..0a759b4 --- /dev/null +++ b/tests/HSchool.Server.Tests/SchoolWorkerShapeTests.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using HSchool.Server.Game; + +namespace HSchool.Server.Tests; + +public class SchoolWorkerShapeTests +{ + [Fact] + public void SchoolWorker_IsOneInternalSealedTypeWithTheMailbox() + { + var worker = typeof(SchoolWorker); + Assert.True(worker.IsSealed); + Assert.False(worker.IsPublic); + Assert.Equal("SchoolWorker", worker.Name); + + var mailbox = worker.GetField("_mailbox", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(mailbox); + + var mailboxTypes = worker.Assembly.GetTypes() + .Where(type => type.GetField("_mailbox", BindingFlags.Instance | BindingFlags.NonPublic) is not null) + .ToArray(); + Assert.Equal([typeof(SchoolWorker)], mailboxTypes); + } +}