Merge branch 'phase/58-worker-card-split'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,22 +12,22 @@ DOM. Разрезать по существующим границам, не м
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `SchoolWorker` — `partial` (почта / тик / запись или эквивалент по швам). Один тип,
|
- [x] `SchoolWorker` — `partial` (почта / тик / запись или эквивалент по швам). Один тип,
|
||||||
тот же `LongRunning`-поток, тот же mailbox. Без `lock` вокруг `School`/`World`
|
тот же `LongRunning`-поток, тот же mailbox. Без `lock` вокруг `School`/`World`
|
||||||
- [ ] `PersonCardReader`: публичный вход по-прежнему `Read(School, personId, locale)`;
|
- [x] `PersonCardReader`: публичный вход по-прежнему `Read(School, personId, locale)`;
|
||||||
сборка вкладок — соседние `internal` helper или `partial`, не второй публичный API
|
сборка вкладок — соседние `internal` helper или `partial`, не второй публичный API
|
||||||
- [ ] `personCard.ts`: вкладки в отдельных модулях; `personCardHost.ts` по-прежнему fetch
|
- [x] `personCard.ts`: вкладки в отдельных модулях; `personCardHost.ts` по-прежнему fetch
|
||||||
- [ ] Инверсия Game → типы из `HSchool.Server.Api` **не** входит: `using` можно оставить
|
- [x] Инверсия Game → типы из `HSchool.Server.Api` **не** входит: `using` можно оставить
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Карточка: нужды, одежда/ноша, связи — как сейчас (`PeopleApiTests`,
|
- [x] Карточка: нужды, одежда/ноша, связи — как сейчас (`PeopleApiTests`,
|
||||||
`InventoryApiTests`, `Card_ConnectionsCarryOnlyThisPersonsOpinions`)
|
`InventoryApiTests`, `Card_ConnectionsCarryOnlyThisPersonsOpinions`)
|
||||||
- [ ] `personCard.test.ts` (вкладки, портрет-кнопки, связи) проходит
|
- [x] `personCard.test.ts` (вкладки, портрет-кнопки, связи) проходит
|
||||||
- [ ] Пауза одной школы не останавливает другую (существующий хостовый факт)
|
- [x] Пауза одной школы не останавливает другую (существующий хостовый факт)
|
||||||
- [ ] Дамп школы по-прежнему 200 на известный id (`Dump_ReturnsPeopleNodesAndTimetable` —
|
- [x] Дамп школы по-прежнему 200 на известный id (`Dump_ReturnsPeopleNodesAndTimetable` —
|
||||||
текущий оракул, ужесточение — фаза 59)
|
текущий оракул, ужесточение — фаза 59)
|
||||||
- [ ] `SchoolWorker` остаётся одним `internal sealed` типом (рефлексия или исходник: нет
|
- [x] `SchoolWorker` остаётся одним `internal sealed` типом (рефлексия или исходник: нет
|
||||||
второго worker-класса с mailbox)
|
второго worker-класса с mailbox)
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [58. SchoolWorker и карточка](58-worker-card-split.md) | 🔄 | partial / модули по швам, один поток |
|
| [58. SchoolWorker и карточка](58-worker-card-split.md) | ✅ | partial / модули по швам, один поток |
|
||||||
|
|
||||||
**Этап C — дамп.**
|
**Этап C — дамп.**
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import type {
|
import type { PersonCard, PersonListItem, PersonLogDir, PersonLogPage, PersonLogQuery, PersonRole } from '../net/api.ts';
|
||||||
PersonCard,
|
|
||||||
PersonListItem,
|
|
||||||
PersonLogDir,
|
|
||||||
PersonLogPage,
|
|
||||||
PersonLogQuery,
|
|
||||||
PersonOpinionLink,
|
|
||||||
PersonRel,
|
|
||||||
PersonRole,
|
|
||||||
} from '../net/api.ts';
|
|
||||||
import { portraitUrl, type PortraitKind, type PortraitPrompt } 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 { 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<PersonRole, MessageKey> = {
|
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||||
student: 'peopleRoleStudent',
|
student: 'peopleRoleStudent',
|
||||||
@@ -72,28 +69,6 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
|
|||||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nowActivityText(
|
|
||||||
card: PersonCard,
|
|
||||||
away: boolean,
|
|
||||||
names: ReadonlyMap<string, string> = 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(
|
export function renderPersonCard(
|
||||||
parent: HTMLElement,
|
parent: HTMLElement,
|
||||||
card: PersonCard,
|
card: PersonCard,
|
||||||
@@ -186,459 +161,6 @@ export function renderPersonCard(
|
|||||||
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait);
|
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<PortraitKind, 'avatar' | 'full'>;
|
|
||||||
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 {
|
function cardMeta(card: PersonCard): string {
|
||||||
const bits = [
|
const bits = [
|
||||||
roleLabels(card.roles),
|
roleLabels(card.roles),
|
||||||
@@ -660,197 +182,3 @@ export function formatPersonPlace(kind: 'here' | 'walking' | 'away', nodeName =
|
|||||||
|
|
||||||
return t('presenceAt', { name: 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string, string> = 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PortraitKind, 'avatar' | 'full'>;
|
||||||
|
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') });
|
||||||
|
}
|
||||||
@@ -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<PersonOpinionLinkResponse>();
|
||||||
|
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<PersonOpinionLinkResponse> CrushesOf(
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
if (person.Bonds is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<PersonOpinionLinkResponse>();
|
||||||
|
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<PersonOpinionLinkResponse> AdmirersOf(
|
||||||
|
Roster roster,
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<PersonOpinionLinkResponse>();
|
||||||
|
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<string, Person> 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<string, Person> 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<PersonRelResponse> RelativesWithOpinions(
|
||||||
|
IReadOnlyList<string> ids,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
Person person,
|
||||||
|
string except,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<PersonRelResponse>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -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<WornItemResponse> Worn(Person person, DefCatalog? catalog, string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<WornItemResponse>();
|
||||||
|
foreach (var item in person.Items)
|
||||||
|
{
|
||||||
|
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<string> layerIds = [];
|
||||||
|
IReadOnlyList<string> 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<CarriedItemResponse> Carried(Person person, DefCatalog? catalog, string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<CarriedItemResponse>();
|
||||||
|
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<string, float>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<LabeledStatResponse>();
|
||||||
|
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<LabeledStatResponse> Skills(
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, float>? 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<LabeledStatResponse>();
|
||||||
|
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<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale)
|
||||||
|
{
|
||||||
|
var rows = new List<DefLabelResponse>(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<NeedStatResponse> Needs(
|
||||||
|
IReadOnlyDictionary<string, float> 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<NeedStatResponse>();
|
||||||
|
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<string> ids, string id)
|
||||||
|
{
|
||||||
|
foreach (var candidate in ids)
|
||||||
|
{
|
||||||
|
if (candidate.Equals(id, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PersonRelResponse> Relatives(
|
||||||
|
IReadOnlyList<string> ids,
|
||||||
|
IReadOnlyDictionary<string, Person> people,
|
||||||
|
string except)
|
||||||
|
{
|
||||||
|
var rows = new List<PersonRelResponse>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ namespace HSchool.Server.Game;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a person card on the school's worker thread so live need values come from the World.
|
/// Builds a person card on the school's worker thread so live need values come from the World.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class PersonCardReader
|
internal static partial class PersonCardReader
|
||||||
{
|
{
|
||||||
private static readonly QueryDescription IdentityAndNeeds =
|
private static readonly QueryDescription IdentityAndNeeds =
|
||||||
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||||
@@ -136,478 +136,4 @@ internal static class PersonCardReader
|
|||||||
});
|
});
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
|
|
||||||
{
|
|
||||||
var rows = new List<LabeledStatResponse>();
|
|
||||||
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<LabeledStatResponse> Skills(
|
|
||||||
Person person,
|
|
||||||
IReadOnlyDictionary<string, float>? 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<LabeledStatResponse>();
|
|
||||||
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<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale)
|
|
||||||
{
|
|
||||||
var rows = new List<DefLabelResponse>(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<NeedStatResponse> Needs(
|
|
||||||
IReadOnlyDictionary<string, float> 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<NeedStatResponse>();
|
|
||||||
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<WornItemResponse> Worn(Person person, DefCatalog? catalog, string locale)
|
|
||||||
{
|
|
||||||
var rows = new List<WornItemResponse>();
|
|
||||||
foreach (var item in person.Items)
|
|
||||||
{
|
|
||||||
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
IReadOnlyList<string> layerIds = [];
|
|
||||||
IReadOnlyList<string> 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<CarriedItemResponse> Carried(Person person, DefCatalog? catalog, string locale)
|
|
||||||
{
|
|
||||||
var rows = new List<CarriedItemResponse>();
|
|
||||||
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<string, float>? 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<string> ids, string id)
|
|
||||||
{
|
|
||||||
foreach (var candidate in ids)
|
|
||||||
{
|
|
||||||
if (candidate.Equals(id, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<PersonRelResponse> Relatives(
|
|
||||||
IReadOnlyList<string> ids,
|
|
||||||
IReadOnlyDictionary<string, Person> people,
|
|
||||||
string except)
|
|
||||||
{
|
|
||||||
var rows = new List<PersonRelResponse>();
|
|
||||||
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<PersonOpinionLinkResponse>();
|
|
||||||
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<PersonOpinionLinkResponse> CrushesOf(
|
|
||||||
Person person,
|
|
||||||
IReadOnlyDictionary<string, Person> people,
|
|
||||||
DefCatalog? catalog,
|
|
||||||
string locale)
|
|
||||||
{
|
|
||||||
if (person.Bonds is null)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows = new List<PersonOpinionLinkResponse>();
|
|
||||||
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<PersonOpinionLinkResponse> AdmirersOf(
|
|
||||||
Roster roster,
|
|
||||||
Person person,
|
|
||||||
IReadOnlyDictionary<string, Person> people,
|
|
||||||
DefCatalog? catalog,
|
|
||||||
string locale)
|
|
||||||
{
|
|
||||||
var rows = new List<PersonOpinionLinkResponse>();
|
|
||||||
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<string, Person> 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<string, Person> 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<PersonRelResponse> RelativesWithOpinions(
|
|
||||||
IReadOnlyList<string> ids,
|
|
||||||
IReadOnlyDictionary<string, Person> people,
|
|
||||||
Person person,
|
|
||||||
string except,
|
|
||||||
DefCatalog? catalog,
|
|
||||||
string locale)
|
|
||||||
{
|
|
||||||
var rows = new List<PersonRelResponse>();
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DefCatalog, Roster, ApplicantPool, StaffingOutcome> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>.
|
||||||
|
/// A single click still lands within that window; a burst collapses into one write.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blocks this dedicated thread until the next tick. Completing the wait on the pool is fine;
|
||||||
|
/// <see cref="School.Tick"/> then runs here, not as a pool callback.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user