Enhance school creation and staffing management with native language support
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 14s

- Updated protocol documentation to include `nativeLanguages` in `nameSets` and added `nativeLanguage` to school creation options.
- Enhanced the UI for school creation to allow selection of native languages, improving user experience.
- Revised API interfaces to accommodate new native language features, ensuring proper data handling.
- Improved localization strings to support new native language functionalities in both English and Russian.
- Updated tests to validate the new native language features and ensure robust functionality in staffing scenarios.
This commit is contained in:
Leonid Pershin
2026-08-19 22:22:31 +03:00
parent 21d79cb49b
commit 5a00ad7746
48 changed files with 2064 additions and 312 deletions
+30 -2
View File
@@ -42,10 +42,14 @@ const ru = {
errorUnknownMod: 'Выбранный мод не найден.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды',
nameSetTitle: 'Набор имён',
nativeLanguageTitle: 'Родной язык',
nativeLanguageRandom: 'Случайный',
randomNativeTitle: 'Выбрать язык наугад',
coreModLocked: '{id} (всегда включён)',
mapEditorTitle: 'Карта',
editMap: 'Редактировать карту',
@@ -135,6 +139,16 @@ const ru = {
staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.',
staffApplicants: 'Соискатели',
staffApplicantsEmpty: 'Пул пуст.',
staffApplicantsOpen: 'Открыть список',
staffApplicantsCount: 'В пуле: {n}',
staffApplicantsSearch: 'Имя',
staffApplicantsSearchHint: 'Поиск по имени',
staffApplicantsKind: 'Кто',
staffApplicantsKindAll: 'Все',
staffApplicantsKindParent: 'Родители',
staffApplicantsKindOther: 'Со стороны',
staffApplicantsNoMatches: 'Никто не подходит под фильтр.',
staffApplicantsPickHint: 'Выберите соискателя в списке.',
staffHired: 'Штат',
staffHiredEmpty: 'Никого не наняли.',
staffColName: 'ФИО',
@@ -148,7 +162,7 @@ const ru = {
staffAsk: '{hourly}/ч · {monthly}/мес',
staffSubjectRange: '{label} ({min}{max})',
staffParent: 'родитель',
staffPickHint: 'Выберите соискателя или работника в списке.',
staffPickHint: 'Выберите сотрудника в списке или откройте соискателей.',
staffHireTitle: 'Наём',
staffHire: 'Нанять',
staffPosition: 'Должность',
@@ -240,10 +254,14 @@ const en: Messages = {
errorUnknownMod: 'A selected mod is missing.',
errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that name set.',
catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods',
nameSetTitle: 'Name set',
nativeLanguageTitle: 'Native language',
nativeLanguageRandom: 'Random',
randomNativeTitle: 'Pick a language at random',
coreModLocked: '{id} (always on)',
mapEditorTitle: 'Map',
editMap: 'Edit map',
@@ -333,6 +351,16 @@ const en: Messages = {
staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.',
staffApplicants: 'Applicants',
staffApplicantsEmpty: 'The pool is empty.',
staffApplicantsOpen: 'Open list',
staffApplicantsCount: '{n} in the pool',
staffApplicantsSearch: 'Name',
staffApplicantsSearchHint: 'Search by name',
staffApplicantsKind: 'Who',
staffApplicantsKindAll: 'All',
staffApplicantsKindParent: 'Parents',
staffApplicantsKindOther: 'Outside',
staffApplicantsNoMatches: 'Nobody matches the filter.',
staffApplicantsPickHint: 'Select an applicant in the list.',
staffHired: 'Staff',
staffHiredEmpty: 'Nobody hired yet.',
staffColName: 'Name',
@@ -346,7 +374,7 @@ const en: Messages = {
staffAsk: '{hourly}/h · {monthly}/mo',
staffSubjectRange: '{label} ({min}{max})',
staffParent: 'parent',
staffPickHint: 'Select an applicant or a staff member in the list.',
staffPickHint: 'Select a staff member in the list, or open the applicants.',
staffHireTitle: 'Hire',
staffHire: 'Hire',
staffPosition: 'Position',
+10 -1
View File
@@ -50,6 +50,7 @@ export interface CreateSchoolOptions {
readonly modIds?: readonly string[];
readonly map?: MapLayout;
readonly nameSetId?: string;
readonly nativeLanguage?: string;
}
export async function createSchool(
@@ -66,6 +67,7 @@ export async function createSchool(
modIds: extras.modIds ?? [],
map: extras.map ?? null,
nameSetId: extras.nameSetId ?? null,
nativeLanguage: extras.nativeLanguage ?? null,
}),
});
}
@@ -81,6 +83,12 @@ export interface DefInfo {
readonly pupilSlots?: number;
}
export interface NameSetInfo {
readonly defName: string;
readonly label: string;
readonly nativeLanguages: readonly DefInfo[];
}
export interface RoomSlotInfo {
readonly key: string;
readonly thing: string;
@@ -153,7 +161,7 @@ export interface CatalogResponse {
readonly rooms: readonly RoomInfo[];
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
readonly nameSets: readonly DefInfo[];
readonly nameSets: readonly NameSetInfo[];
readonly subjects: readonly SubjectInfo[];
readonly dayFrame: DayFrameInfo | null;
readonly holidays: readonly HolidayInfo[];
@@ -324,6 +332,7 @@ export interface StaffingApplicant {
readonly hourlyWageAsk: number;
readonly monthlyBase: number;
readonly skills: readonly LabeledStat[];
readonly traits: readonly DefLabel[];
}
export interface StaffMember {
+51 -1
View File
@@ -792,8 +792,14 @@ body {
font-size: 13px;
}
.staffing__summary {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 13px;
}
.staffing__list-section .people__table-wrap {
max-height: 220px;
max-height: 320px;
}
.timetable {
@@ -1015,6 +1021,50 @@ body {
margin-top: 12px;
}
.dialog--applicants .screen__header,
.dialog--applicants .staffing__error,
.dialog--applicants .people__toolbar {
flex: 0 0 auto;
}
.dialog--applicants[open] {
width: min(1280px, calc(100vw - 32px));
height: min(840px, calc(100dvh - 48px));
max-height: calc(100dvh - 48px);
overflow: hidden;
}
.dialog--applicants .applicants__split {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr);
gap: 16px;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.dialog--applicants .applicants__table {
min-height: 0;
}
.dialog--applicants .applicants__card {
min-height: 0;
overflow: auto;
padding: 0;
}
.dialog--applicants .applicants__count {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.people__table td.applicants__cell--wrap {
white-space: normal;
max-width: 28em;
line-height: 1.35;
}
.dialog::backdrop {
background: rgba(6, 9, 14, 0.7);
}
@@ -0,0 +1,412 @@
import {
fetchPerson,
hireStaff,
type PersonCard,
type Staffing,
type StaffingApplicant,
} from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
import { Modal } from './modal.ts';
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
import {
actionError,
fillSelect,
formatMoney,
formatSkill,
formatTraits,
parseOptionalInt,
} from './staffingUi.ts';
const TEACHER = 'Teacher';
type ApplicantSort = 'name' | 'age' | 'ask';
const SORT_COLUMNS: readonly { sort: ApplicantSort; label: MessageKey }[] = [
{ sort: 'name', label: 'staffColName' },
{ sort: 'age', label: 'peopleColAge' },
{ sort: 'ask', label: 'staffColAsk' },
];
export interface ApplicantsDialogOptions {
readonly schoolId: number;
readonly staffing: Staffing;
readonly locate: (id: string) => string;
readonly selectedId?: string;
readonly onChange: (staffing: Staffing, hiredId: string) => void;
}
/**
* Full-size applicant browser. The management pane only has room for a summary; comparing
* skills and asks needs a table that can actually scroll, plus the person card beside it.
*/
export class ApplicantsDialog {
private readonly error = el('p', { class: 'staffing__error' });
private readonly title = el('h2', { class: 'screen__title' });
private readonly count = el('p', { class: 'applicants__count' });
private readonly searchLabel = el('span', { class: 'people__label' });
private readonly search = el('input', { class: 'input people__input', type: 'search' });
private readonly sexLabel = el('span', { class: 'people__label' });
private readonly sexSelect = el('select', { class: 'input people__input' });
private readonly kindLabel = el('span', { class: 'people__label' });
private readonly kindSelect = el('select', { class: 'input people__input' });
private readonly ageFromLabel = el('span', { class: 'people__label' });
private readonly ageMinInput = el('input', { class: 'input input--count', type: 'number' });
private readonly ageToLabel = el('span', { class: 'people__label' });
private readonly ageMaxInput = el('input', { class: 'input input--count', type: 'number' });
private readonly table = el('table', { class: 'people__table' });
private readonly empty = el('p', { class: 'panel__empty' });
private readonly card = el('aside', { class: 'panel__body people__card applicants__card' });
private readonly positionSelect = el('select', { class: 'input people__input' });
private readonly closeButton = el('button', { class: 'button button--primary', type: 'button' });
private modal: Modal<void> | null = null;
private staffing: Staffing;
private selectedId: string | null;
private sort: ApplicantSort = 'name';
private dir: 'asc' | 'desc' = 'asc';
private cardToken = 0;
private busy = false;
private painted: PersonCard | null = null;
constructor(private readonly options: ApplicantsDialogOptions) {
this.staffing = options.staffing;
this.selectedId = options.selectedId ?? null;
this.error.hidden = true;
this.ageMinInput.min = '0';
this.ageMaxInput.min = '0';
this.search.addEventListener('input', () => this.paintList());
this.sexSelect.addEventListener('change', () => this.paintList());
this.kindSelect.addEventListener('change', () => this.paintList());
this.ageMinInput.addEventListener('change', () => this.paintList());
this.ageMaxInput.addEventListener('change', () => this.paintList());
this.closeButton.addEventListener('click', () => this.modal?.close(undefined));
}
open(): Promise<void> {
const modal = new Modal<void>(undefined);
this.modal = modal;
modal.element.classList.add('dialog--screen', 'dialog--applicants');
modal.element.append(
el('header', { class: 'screen__header' }, this.title, el('div', { class: 'screen__actions' }, this.count)),
this.error,
el(
'div',
{ class: 'people__toolbar' },
el('label', { class: 'people__field' }, this.searchLabel, this.search),
el('label', { class: 'people__field' }, this.sexLabel, this.sexSelect),
el('label', { class: 'people__field' }, this.kindLabel, this.kindSelect),
el('label', { class: 'people__field' }, this.ageFromLabel, this.ageMinInput),
el('label', { class: 'people__field' }, this.ageToLabel, this.ageMaxInput),
),
el(
'div',
{ class: 'applicants__split' },
el('div', { class: 'people__table-wrap applicants__table' }, this.table, this.empty),
this.card,
),
el('div', { class: 'dialog__actions' }, this.closeButton),
);
this.localize();
return modal.open(this.search).finally(() => {
this.modal = null;
});
}
localize(): void {
this.title.textContent = t('staffApplicants');
this.searchLabel.textContent = t('staffApplicantsSearch');
this.search.placeholder = t('staffApplicantsSearchHint');
this.sexLabel.textContent = t('peopleSex');
this.kindLabel.textContent = t('staffApplicantsKind');
this.ageFromLabel.textContent = t('peopleAgeFrom');
this.ageToLabel.textContent = t('peopleAgeTo');
this.closeButton.textContent = t('done');
this.empty.textContent = t('staffApplicantsEmpty');
fillFixedSelect(this.sexSelect, [
{ value: '', label: t('peopleSexAll') },
{ value: 'female', label: t('peopleFemale') },
{ value: 'male', label: t('peopleMale') },
]);
fillFixedSelect(this.kindSelect, [
{ value: '', label: t('staffApplicantsKindAll') },
{ value: 'parent', label: t('staffApplicantsKindParent') },
{ value: 'other', label: t('staffApplicantsKindOther') },
]);
this.paintList();
if (this.selectedId !== null) {
void this.openCard(this.selectedId);
} else {
this.paintCard(null);
}
}
setStaffing(staffing: Staffing): void {
this.staffing = staffing;
if (this.selectedId !== null && !staffing.applicants.some((row) => row.id === this.selectedId)) {
this.selectedId = null;
this.paintCard(null);
}
this.paintList();
if (this.selectedId !== null) {
this.appendHireIfNeeded();
}
}
select(id: string): void {
this.selectedId = id;
this.clearError();
this.paintList();
void this.openCard(id);
}
private filtered(): StaffingApplicant[] {
const query = this.search.value.trim().toLocaleLowerCase(getLocale());
const sex = this.sexSelect.value;
const kind = this.kindSelect.value;
const ageMin = parseOptionalInt(this.ageMinInput.value);
const ageMax = parseOptionalInt(this.ageMaxInput.value);
const rows = this.staffing.applicants.filter((applicant) => {
if (query.length > 0 && !applicant.fullName.toLocaleLowerCase(getLocale()).includes(query)) {
return false;
}
if (sex === 'female' && !applicant.female) {
return false;
}
if (sex === 'male' && applicant.female) {
return false;
}
if (kind === 'parent' && !applicant.isParent) {
return false;
}
if (kind === 'other' && applicant.isParent) {
return false;
}
if (ageMin !== undefined && applicant.age < ageMin) {
return false;
}
if (ageMax !== undefined && applicant.age > ageMax) {
return false;
}
return true;
});
const factor = this.dir === 'asc' ? 1 : -1;
rows.sort((left, right) => {
const compared =
this.sort === 'age'
? left.age - right.age
: this.sort === 'ask'
? left.hourlyWageAsk - right.hourlyWageAsk
: left.fullName.localeCompare(right.fullName, getLocale());
return compared === 0 ? left.id.localeCompare(right.id, getLocale()) : compared * factor;
});
return rows;
}
private paintList(): void {
const total = this.staffing.applicants.length;
this.count.textContent = t('staffApplicantsCount', { n: total });
const rows = this.filtered();
const hasRows = rows.length > 0;
this.empty.hidden = hasRows;
this.table.hidden = !hasRows;
this.empty.textContent = total === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsNoMatches');
clear(this.table);
if (!hasRows) {
return;
}
const head = el('tr');
for (const column of SORT_COLUMNS) {
const button = el('button', {
class: 'people__sort',
type: 'button',
text: t(column.label),
onClick: () => this.onSort(column.sort),
});
button.setAttribute(
'aria-sort',
this.sort === column.sort ? (this.dir === 'asc' ? 'ascending' : 'descending') : 'none',
);
head.append(el('th', {}, button));
}
head.append(
el('th', { text: t('staffColPay') }),
el('th', { text: t('peopleColSex') }),
el('th', { text: t('staffColSkills') }),
el('th', { text: t('peopleTraits') }),
);
const body = el('tbody');
for (const applicant of rows) {
const selected = applicant.id === this.selectedId;
const row = el('tr', {
class: selected ? 'people__row people__row--active' : 'people__row',
dataset: { personId: applicant.id },
onClick: () => this.select(applicant.id),
});
const name = el('td', { text: applicant.fullName });
if (applicant.isParent) {
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
}
row.append(
name,
el('td', { text: String(applicant.age) }),
el('td', { text: formatMoney(applicant.hourlyWageAsk) }),
el('td', { text: formatMoney(applicant.monthlyBase) }),
el('td', { text: applicant.female ? t('peopleFemale') : t('peopleMale') }),
el('td', {
class: 'applicants__cell--wrap',
text: applicant.skills.map(formatSkill).join(' · ') || '—',
}),
el('td', { class: 'applicants__cell--wrap', text: formatTraits(applicant.traits) }),
);
body.append(row);
}
this.table.append(el('thead', {}, head), body);
}
private onSort(sort: ApplicantSort): void {
if (this.sort === sort) {
this.dir = this.dir === 'asc' ? 'desc' : 'asc';
} else {
this.sort = sort;
this.dir = 'asc';
}
this.paintList();
}
private async openCard(personId: string): Promise<void> {
const token = ++this.cardToken;
try {
const card = await fetchPerson(this.options.schoolId, personId, getLocale());
if (token !== this.cardToken) {
return;
}
this.paintCard(card);
} catch {
if (token !== this.cardToken) {
return;
}
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('peopleCardFailed') }));
}
}
private paintCard(card: PersonCard | null): void {
clear(this.card);
this.painted = card;
if (card === null) {
this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') }));
return;
}
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id));
this.appendHireIfNeeded();
}
private appendHireIfNeeded(): void {
const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id);
if (applicant === undefined) {
return;
}
fillSelect(
this.positionSelect,
this.staffing.positions.map((row) => ({ value: row.defName, label: row.label })),
TEACHER,
);
const actions = el('div', { class: 'staffing__actions' });
actions.append(
el('h4', { class: 'people__section-title', text: t('staffHireTitle') }),
el(
'label',
{ class: 'people__field' },
el('span', { class: 'people__label', text: t('staffPosition') }),
this.positionSelect,
),
el('button', {
class: 'button',
type: 'button',
text: t('staffHire'),
disabled: this.busy,
onClick: () => void this.hire(applicant.id),
}),
);
this.card.append(actions);
}
private openRelative(personId: string): void {
if (this.staffing.applicants.some((row) => row.id === personId)) {
this.select(personId);
}
}
private placeOf(id: string): string {
return this.options.locate(id) || formatPersonPlace('away');
}
private async hire(personId: string): Promise<void> {
if (this.busy) {
return;
}
this.busy = true;
this.clearError();
try {
const staffing = await hireStaff(this.options.schoolId, personId, this.positionSelect.value, getLocale());
this.staffing = staffing;
this.selectedId = null;
this.options.onChange(staffing, personId);
this.paintList();
this.paintCard(null);
} catch (error) {
this.showError(actionError(error));
} finally {
this.busy = false;
if (this.painted !== null && this.selectedId === this.painted.id) {
this.paintCard(this.painted);
}
}
}
private showError(message: string): void {
this.error.hidden = false;
this.error.textContent = message;
}
private clearError(): void {
this.error.hidden = true;
this.error.textContent = '';
}
}
function fillFixedSelect(select: HTMLSelectElement, items: readonly { value: string; label: string }[]): void {
const current = select.value;
select.replaceChildren();
for (const item of items) {
const option = el('option', { text: item.label });
option.value = item.value;
select.append(option);
}
if ([...select.options].some((option) => option.value === current)) {
select.value = current;
}
}
@@ -33,6 +33,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null;
let nameSetId: string | null = null;
let nativeLanguageId: string | null = null;
const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' });
@@ -44,6 +45,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
nameSetSelect.addEventListener('change', () => {
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value;
nativeLanguageId = null;
if (catalog !== null) {
paintNativeLanguages(catalog);
}
});
const nativeLabel = el('span', { class: 'field__label' });
const nativeSelect = el('select', { class: 'input' });
const nativeRandomButton = el('button', { class: 'button', type: 'button' });
const nativeField = el(
'div',
{ class: 'field' },
nativeLabel,
el('div', { class: 'field__row' }, nativeSelect, nativeRandomButton),
);
nativeSelect.addEventListener('change', () => {
nativeLanguageId = nativeSelect.value === '' ? null : nativeSelect.value;
});
const mapLabel = el('span', { class: 'field__label' });
@@ -87,6 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField,
nameSetField,
nativeField,
mapField,
error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
@@ -99,6 +118,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const waiting = value || catalog === null;
submitButton.toggleAttribute('disabled', waiting);
randomButton.toggleAttribute('disabled', value);
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
editMapButton.toggleAttribute('disabled', waiting);
};
@@ -123,7 +143,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
currentMap = structuredClone(next.defaultMap);
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) {
nativeLanguageId = null;
}
paintNameSets(next);
paintNativeLanguages(next);
paintMapHint();
setBusy(busy);
};
@@ -140,6 +164,24 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nameSetSelect.disabled = next.nameSets.length <= 1;
};
const paintNativeLanguages = (next: CatalogResponse): void => {
const languages = languagesOf(next, nameSetId);
nativeSelect.replaceChildren();
const random = el('option', { text: t('nativeLanguageRandom') });
random.value = '';
nativeSelect.append(random);
for (const language of languages) {
const option = el('option', { text: language.label });
option.value = language.defName;
nativeSelect.append(option);
}
nativeSelect.value = nativeLanguageId ?? '';
nativeSelect.disabled = languages.length <= 1;
nativeRandomButton.toggleAttribute('disabled', busy || languages.length <= 1);
nativeField.hidden = languages.length === 0;
};
const reloadCatalog = async (): Promise<void> => {
try {
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
@@ -193,6 +235,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle');
nameSetLabel.textContent = t('nameSetTitle');
nativeLabel.textContent = t('nativeLanguageTitle');
nativeRandomButton.textContent = t('randomName');
nativeRandomButton.title = t('randomNativeTitle');
nameLabel.textContent = t('schoolName');
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle');
@@ -205,6 +250,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
paintMapHint();
if (catalog !== null) {
paintNameSets(catalog);
paintNativeLanguages(catalog);
}
};
@@ -222,6 +268,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
});
});
nativeRandomButton.addEventListener('click', () => {
if (busy || catalog === null) {
return;
}
const languages = languagesOf(catalog, nameSetId);
if (languages.length === 0) {
return;
}
nativeLanguageId = languages[Math.floor(Math.random() * languages.length)]?.defName ?? null;
nativeSelect.value = nativeLanguageId ?? '';
});
randomButton.addEventListener('click', () => {
if (busy) {
return;
@@ -256,6 +316,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
modIds: [...extraModIds],
map: currentMap,
nameSetId: nameSetId ?? undefined,
nativeLanguage: nativeLanguageId ?? undefined,
})
.then((school) => modal.close(school))
.catch((reason: unknown) => {
@@ -270,6 +331,14 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return modal.open(nameInput);
}
function languagesOf(catalog: CatalogResponse | null, nameSetId: string | null): readonly { readonly defName: string; readonly label: string }[] {
if (catalog === null || nameSetId === null) {
return [];
}
return catalog.nameSets.find((set) => set.defName === nameSetId)?.nativeLanguages ?? [];
}
function mapsEqual(left: MapLayout, right: MapLayout): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
@@ -294,6 +363,8 @@ function describe(reason: unknown): string {
return t('errorInvalidCatalog');
case 'unknown-name-set':
return t('errorUnknownNameSet');
case 'unknown-native-language':
return t('errorUnknownNativeLanguage');
default:
return reason.message;
}
+89 -213
View File
@@ -1,32 +1,27 @@
import {
ApiError,
assignSubject,
fetchPerson,
fetchStaffing,
fetchTimetable,
hireStaff,
unassignSubject,
type PersonCard,
type Staffing,
type StaffingApplicant,
type StaffMember,
type Timetable,
} from '../net/api.ts';
import { getLocale, intlTag } from '../i18n/locale.ts';
import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { ApplicantsDialog } from './applicantsDialog.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
const TEACHER = 'Teacher';
type Selection =
| { readonly kind: 'applicant'; readonly id: string }
| { readonly kind: 'staff'; readonly id: string };
/**
* Left: money, coverage, applicants, staff. Right: the same person card as People, plus hire
* and subject actions. HTTP only — the clock stays on the bar above.
* Left: money, coverage, a door into the applicant window, staff. Right: the person card plus
* subject actions. HTTP only — the clock stays on the bar above.
*/
export class ManagementPanel {
readonly listElement: HTMLElement;
@@ -38,8 +33,8 @@ export class ManagementPanel {
private readonly uncovered = el('div', { class: 'people__tags' });
private readonly uncoveredEmpty = el('p', { class: 'panel__empty' });
private readonly applicantsTitle = el('h3', { class: 'panel__section-title' });
private readonly applicantsTable = el('table', { class: 'people__table' });
private readonly applicantsEmpty = el('p', { class: 'panel__empty' });
private readonly applicantsSummary = el('p', { class: 'staffing__summary' });
private readonly applicantsButton = el('button', { class: 'button', type: 'button' });
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
private readonly staffTable = el('table', { class: 'people__table' });
private readonly staffEmpty = el('p', { class: 'panel__empty' });
@@ -56,22 +51,23 @@ export class ManagementPanel {
private readonly personGrid = new TimetableGrid({ editable: false, showClass: true });
private readonly personTimetableTitle = el('h4', { class: 'people__section-title' });
private readonly card = el('aside', { class: 'panel__body people__card' });
private readonly positionSelect = el('select', { class: 'input people__input' });
private readonly subjectSelect = el('select', { class: 'input people__input' });
private schoolId: number | null = null;
private staffing: Staffing | null = null;
private timetable: Timetable | null = null;
private classId: string | null = null;
private selection: Selection | null = null;
private selectedId: string | null = null;
private loadToken = 0;
private cardToken = 0;
private busy = false;
private locate: ((id: string) => string) | null = null;
private painted: PersonCard | null = null;
private poolDialog: ApplicantsDialog | null = null;
constructor() {
this.error.hidden = true;
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
this.listElement = el(
'div',
{ class: 'panel__body staffing' },
@@ -80,9 +76,10 @@ export class ManagementPanel {
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
el(
'div',
{ class: 'panel__section staffing__list-section' },
{ class: 'panel__section' },
this.applicantsTitle,
el('div', { class: 'people__table-wrap' }, this.applicantsTable, this.applicantsEmpty),
this.applicantsSummary,
this.applicantsButton,
),
el(
'div',
@@ -109,18 +106,19 @@ export class ManagementPanel {
localize(): void {
this.uncoveredTitle.textContent = t('staffUncovered');
this.applicantsTitle.textContent = t('staffApplicants');
this.applicantsButton.textContent = t('staffApplicantsOpen');
this.staffTitle.textContent = t('staffHired');
this.timetableTitle.textContent = t('timetableTitle');
this.classLabel.textContent = t('timetableClass');
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
this.staffEmpty.textContent = t('staffHiredEmpty');
this.timetableGrid.localize();
this.personGrid.localize();
this.personTimetableTitle.textContent = t('timetableTitle');
this.paint();
if (this.selection !== null) {
void this.openCard(this.selection);
this.poolDialog?.localize();
if (this.selectedId !== null) {
void this.openCard(this.selectedId);
} else {
this.paintCard(null);
}
@@ -130,7 +128,7 @@ export class ManagementPanel {
const switched = this.schoolId !== schoolId;
this.schoolId = schoolId;
if (switched) {
this.selection = null;
this.selectedId = null;
this.staffing = null;
this.timetable = null;
this.classId = null;
@@ -167,9 +165,10 @@ export class ManagementPanel {
this.syncClass(timetable);
this.syncSelection();
this.paint();
this.poolDialog?.setStaffing(staffing);
this.timetableGrid.setTable(timetable, this.classId);
if (this.selection !== null) {
void this.openCard(this.selection);
if (this.selectedId !== null) {
void this.openCard(this.selectedId);
} else {
this.paintCard(null);
}
@@ -207,21 +206,16 @@ export class ManagementPanel {
private syncSelection(): void {
const staffing = this.staffing;
const selected = this.selection;
const selected = this.selectedId;
if (staffing === null || selected === null) {
return;
}
if (selected.kind === 'applicant' && staffing.applicants.some((row) => row.id === selected.id)) {
if (staffing.staff.some((row) => row.id === selected)) {
return;
}
if (staffing.staff.some((row) => row.id === selected.id)) {
this.selection = { kind: 'staff', id: selected.id };
return;
}
this.selection = null;
this.selectedId = null;
}
private paint(): void {
@@ -274,55 +268,10 @@ export class ManagementPanel {
}
private paintApplicants(staffing: Staffing | null): void {
const rows = staffing?.applicants ?? [];
this.applicantsEmpty.hidden = rows.length > 0;
this.applicantsTable.hidden = rows.length === 0;
clear(this.applicantsTable);
if (rows.length === 0) {
return;
}
const body = el('tbody');
this.applicantsTable.append(
el(
'thead',
{},
el(
'tr',
{},
el('th', { text: t('staffColName') }),
el('th', { text: t('staffColAsk') }),
el('th', { text: t('staffColSkills') }),
),
),
body,
);
for (const applicant of rows) {
const selected = this.selection?.kind === 'applicant' && this.selection.id === applicant.id;
const row = el('tr', {
class: selected ? 'people__row people__row--active' : 'people__row',
dataset: { personId: applicant.id },
onClick: () => void this.select({ kind: 'applicant', id: applicant.id }),
});
const ask = el('td', {
text: t('staffAsk', {
hourly: formatMoney(applicant.hourlyWageAsk),
monthly: formatMoney(applicant.monthlyBase),
}),
});
const name = el('td', { text: applicant.fullName });
if (applicant.isParent) {
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
}
row.append(
name,
ask,
el('td', { text: applicant.skills.map((skill) => skill.label).join(', ') || '—' }),
);
body.append(row);
}
const count = staffing?.applicants.length ?? 0;
this.applicantsSummary.textContent =
count === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsCount', { n: count });
this.applicantsButton.disabled = count === 0;
}
private paintStaff(staffing: Staffing | null): void {
@@ -343,9 +292,12 @@ export class ManagementPanel {
'tr',
{},
el('th', { text: t('staffColName') }),
el('th', { text: t('peopleColAge') }),
el('th', { text: t('peopleColSex') }),
el('th', { text: t('staffColPosition') }),
el('th', { text: t('staffColSubjects') }),
el('th', { text: t('staffColHours') }),
el('th', { text: t('staffColAsk') }),
el('th', { text: t('staffColPay') }),
),
),
@@ -353,11 +305,11 @@ export class ManagementPanel {
);
for (const member of rows) {
const selected = this.selection?.kind === 'staff' && this.selection.id === member.id;
const selected = this.selectedId === member.id;
const row = el('tr', {
class: selected ? 'people__row people__row--active' : 'people__row',
dataset: { personId: member.id },
onClick: () => void this.select({ kind: 'staff', id: member.id }),
onClick: () => void this.select(member.id),
});
const name = el('td', { text: member.fullName });
if (member.isParent) {
@@ -366,23 +318,62 @@ export class ManagementPanel {
row.append(
name,
el('td', { text: String(member.age) }),
el('td', { text: member.female ? t('peopleFemale') : t('peopleMale') }),
el('td', { text: member.positionLabel }),
el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
el('td', { class: 'applicants__cell--wrap', text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
el('td', { text: t('staffHours', { n: formatHours(member.weeklyHours) }) }),
el('td', { text: formatMoney(member.hourlyWageAsk) }),
el('td', { text: formatMoney(member.monthlyPay) }),
);
body.append(row);
}
}
private async select(selection: Selection): Promise<void> {
this.selection = selection;
private async select(personId: string): Promise<void> {
this.selectedId = personId;
this.clearError();
this.paint();
await this.openCard(selection);
await this.openCard(personId);
}
private async openCard(selection: Selection): Promise<void> {
private async openApplicants(selectedId?: string): Promise<void> {
const schoolId = this.schoolId;
const staffing = this.staffing;
if (schoolId === null || staffing === null || staffing.applicants.length === 0) {
return;
}
if (this.poolDialog !== null) {
if (selectedId !== undefined) {
this.poolDialog.select(selectedId);
}
return;
}
const dialog = new ApplicantsDialog({
schoolId,
staffing,
locate: (id) => this.placeOf(id),
selectedId,
onChange: (next, hiredId) => {
this.staffing = next;
this.selectedId = hiredId;
this.paint();
void this.refreshTimetable();
void this.openCard(hiredId);
},
});
this.poolDialog = dialog;
try {
await dialog.open();
} finally {
this.poolDialog = null;
}
}
private async openCard(personId: string): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null) {
return;
@@ -390,7 +381,7 @@ export class ManagementPanel {
const token = ++this.cardToken;
try {
const card = await fetchPerson(schoolId, selection.id, getLocale());
const card = await fetchPerson(schoolId, personId, getLocale());
if (token !== this.cardToken) {
return;
}
@@ -462,16 +453,16 @@ export class ManagementPanel {
private async openRelative(personId: string): Promise<void> {
const staffing = this.staffing;
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
await this.select({ kind: 'staff', id: personId });
await this.select(personId);
return;
}
if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) {
await this.select({ kind: 'applicant', id: personId });
await this.openApplicants(personId);
return;
}
await this.select({ kind: 'staff', id: personId });
await this.select(personId);
}
private appendActions(personId: string): void {
@@ -480,45 +471,12 @@ export class ManagementPanel {
return;
}
const applicant = staffing.applicants.find((row) => row.id === personId);
if (applicant !== undefined) {
this.appendHire(applicant);
return;
}
const member = staffing.staff.find((row) => row.id === personId);
if (member !== undefined && member.position === TEACHER) {
this.appendSubjects(member);
}
}
private appendHire(applicant: StaffingApplicant): void {
const staffing = this.staffing;
if (staffing === null) {
return;
}
fillSelect(
this.positionSelect,
staffing.positions.map((row) => ({ value: row.defName, label: row.label })),
TEACHER,
);
const actions = el('div', { class: 'staffing__actions' });
actions.append(
el('h4', { class: 'people__section-title', text: t('staffHireTitle') }),
el('label', { class: 'people__field' }, el('span', { class: 'people__label', text: t('staffPosition') }), this.positionSelect),
el('button', {
class: 'button',
type: 'button',
text: t('staffHire'),
disabled: this.busy,
onClick: () => void this.hire(applicant.id),
}),
);
this.card.append(actions);
}
private appendSubjects(member: StaffMember): void {
const staffing = this.staffing;
if (staffing === null) {
@@ -574,30 +532,6 @@ export class ManagementPanel {
this.card.append(actions);
}
private async hire(personId: string): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null || this.busy) {
return;
}
this.busy = true;
this.clearError();
try {
const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale());
this.staffing = staffing;
this.selection = { kind: 'staff', id: personId };
await this.refreshTimetable();
} catch (error) {
this.showError(actionError(error));
} finally {
this.busy = false;
this.paint();
if (this.selection !== null) {
await this.openCard(this.selection);
}
}
}
private async assign(personId: string): Promise<void> {
const schoolId = this.schoolId;
const subject = this.subjectSelect.value;
@@ -609,15 +543,16 @@ export class ManagementPanel {
this.clearError();
try {
this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
this.selection = { kind: 'staff', id: personId };
this.selectedId = personId;
this.poolDialog?.setStaffing(this.staffing);
await this.refreshTimetable();
} catch (error) {
this.showError(actionError(error));
} finally {
this.busy = false;
this.paint();
if (this.selection !== null) {
await this.openCard(this.selection);
if (this.selectedId !== null) {
await this.openCard(this.selectedId);
}
}
}
@@ -632,15 +567,16 @@ export class ManagementPanel {
this.clearError();
try {
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
this.selection = { kind: 'staff', id: personId };
this.selectedId = personId;
this.poolDialog?.setStaffing(this.staffing);
await this.refreshTimetable();
} catch (error) {
this.showError(actionError(error));
} finally {
this.busy = false;
this.paint();
if (this.selection !== null) {
await this.openCard(this.selection);
if (this.selectedId !== null) {
await this.openCard(this.selectedId);
}
}
}
@@ -670,63 +606,3 @@ export class ManagementPanel {
this.error.textContent = '';
}
}
function fillSelect(
select: HTMLSelectElement,
items: readonly { value: string; label: string }[],
preferred: string,
): void {
const current = select.value;
select.replaceChildren();
for (const item of items) {
const option = el('option', { text: item.label });
option.value = item.value;
select.append(option);
}
if (items.some((item) => item.value === current)) {
select.value = current;
} else if (items.some((item) => item.value === preferred)) {
select.value = preferred;
}
}
function formatHours(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value);
}
function formatMoney(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value);
}
function actionError(error: unknown): string {
if (!(error instanceof ApiError)) {
return t('staffActionFailed');
}
switch (error.code) {
case 'payroll-exceeded':
return t('staffErrorPayroll', {
allocated: formatMoney(error.allocated ?? 0),
payroll: formatMoney(error.payroll ?? 0),
remaining: formatMoney(error.remaining ?? 0),
attempted: formatMoney(error.attempted ?? 0),
});
case 'already-hired':
return t('staffErrorHired');
case 'no-opening':
return t('staffErrorNoOpening');
case 'unknown-applicant':
return t('staffErrorUnknownApplicant');
case 'unknown-position':
return t('staffErrorPosition');
case 'not-teacher':
return t('staffErrorNotTeacher');
case 'already-assigned':
return t('staffErrorAssigned');
case 'unknown-subject':
return t('staffErrorSubject');
default:
return error.message.length > 0 ? error.message : t('staffActionFailed');
}
}
+81
View File
@@ -0,0 +1,81 @@
import { ApiError, type DefLabel } from '../net/api.ts';
import { intlTag } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
export function fillSelect(
select: HTMLSelectElement,
items: readonly { value: string; label: string }[],
preferred: string,
): void {
const current = select.value;
select.replaceChildren();
for (const item of items) {
const option = el('option', { text: item.label });
option.value = item.value;
select.append(option);
}
if (items.some((item) => item.value === current)) {
select.value = current;
} else if (items.some((item) => item.value === preferred)) {
select.value = preferred;
}
}
export function formatHours(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value);
}
export function formatMoney(value: number): string {
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value);
}
export function formatSkill(skill: { readonly label: string; readonly value: string }): string {
return `${skill.label} ${skill.value}`;
}
export function formatTraits(traits: readonly DefLabel[]): string {
return traits.map((trait) => trait.label).join(', ') || '—';
}
export function actionError(error: unknown): string {
if (!(error instanceof ApiError)) {
return t('staffActionFailed');
}
switch (error.code) {
case 'payroll-exceeded':
return t('staffErrorPayroll', {
allocated: formatMoney(error.allocated ?? 0),
payroll: formatMoney(error.payroll ?? 0),
remaining: formatMoney(error.remaining ?? 0),
attempted: formatMoney(error.attempted ?? 0),
});
case 'already-hired':
return t('staffErrorHired');
case 'no-opening':
return t('staffErrorNoOpening');
case 'unknown-applicant':
return t('staffErrorUnknownApplicant');
case 'unknown-position':
return t('staffErrorPosition');
case 'not-teacher':
return t('staffErrorNotTeacher');
case 'already-assigned':
return t('staffErrorAssigned');
case 'unknown-subject':
return t('staffErrorSubject');
default:
return error.message.length > 0 ? error.message : t('staffActionFailed');
}
}
export function parseOptionalInt(value: string): number | undefined {
if (value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
}
+35 -2
View File
@@ -26,7 +26,7 @@ internal static class PeopleDefValidator
foreach (var names in catalog.NameSets.Values)
{
ValidateNameSet(names);
ValidateNameSet(names, catalog);
}
foreach (var subject in catalog.Subjects.Values)
@@ -171,6 +171,11 @@ internal static class PeopleDefValidator
throw new ContentLoadException($"SkillDef '{skill.DefName}' has a negative stdDev.");
}
if (skill.AdultChance is < 0 or > 1)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' adultChance must be 01.");
}
foreach (var limit in skill.BodyLimits)
{
if (string.IsNullOrWhiteSpace(limit.Attribute))
@@ -500,13 +505,41 @@ internal static class PeopleDefValidator
}
}
private static void ValidateNameSet(NameSetDef names)
private static void ValidateNameSet(NameSetDef names, DefCatalog catalog)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'.");
}
foreach (var native in names.Spoken)
{
if (!catalog.Skills.TryGetValue(native, out var language) || language.Abstract)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' native language '{native}' is not a SkillDef.");
}
}
if (names.RelatedLanguageChance is < 0 or > 1)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageChance must be 01.");
}
if (names.RelatedLanguageStdDev < 0)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageStdDev must not be negative.");
}
if (names.RelatedLanguageMean < 0)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMean must not be negative.");
}
if (names.RelatedLanguageMax < 0)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMax must not be negative.");
}
if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension.");
+40
View File
@@ -120,6 +120,18 @@ public sealed class SkillDef : Def
public IReadOnlyList<AgeMeanPoint> AgeMeans { get; init; } = [];
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
/// <summary>Everyone has this: body, speech, the floor under a profession.</summary>
public bool Always { get; init; }
/// <summary>Adult extras: cook, nurse, secretary. Not rolled for pupils.</summary>
public bool Work { get; init; }
/// <summary>
/// Probability that an adult also has this, on top of the native language. 0 means never
/// by chance — pupils still get it when a subject of their year points here.
/// </summary>
public float AdultChance { get; init; }
}
public sealed class SubjectSkillShare
@@ -338,6 +350,34 @@ public sealed class NameSetDef : Def
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
/// <summary>
/// Languages this set can speak natively. Slavic names cover Russian, Belarusian and
/// Ukrainian; the school picks one at create. Singular <see cref="NativeLanguage"/> is still
/// accepted in JSONC for a one-language pack.
/// </summary>
public IReadOnlyList<string> NativeLanguages { get; init; } = [];
/// <summary>One-language form. Folded into <see cref="Spoken"/> when the list is empty.</summary>
public string? NativeLanguage { get; init; }
public IReadOnlyList<string> Spoken =>
NativeLanguages.Count > 0
? NativeLanguages
: string.IsNullOrWhiteSpace(NativeLanguage) ? [] : [NativeLanguage];
/// <summary>
/// Chance each other language in <see cref="Spoken"/> is present at a low level — a Russian
/// speaker who understands Belarusian. 0 leaves relatives off the card.
/// </summary>
public float RelatedLanguageChance { get; init; }
public float RelatedLanguageMean { get; init; } = 22f;
public float RelatedLanguageStdDev { get; init; } = 8f;
/// <summary>Cap so a related roll cannot look like a native speaker.</summary>
public int RelatedLanguageMax { get; init; } = 40;
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
+15 -8
View File
@@ -16,10 +16,11 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
Roster roster,
int schoolSeed,
string nameSetId,
DateTime asOf)
DateTime asOf,
string? nativeLanguage = null)
{
var week = ApplicantWeeks.Id(asOf);
return Fill(catalog, roster, schoolSeed, nameSetId, asOf, new ApplicantPool(week, 0, []));
return Fill(catalog, roster, schoolSeed, nameSetId, asOf, new ApplicantPool(week, 0, []), nativeLanguage: nativeLanguage);
}
/// <summary>
@@ -31,7 +32,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
Roster roster,
int schoolSeed,
string nameSetId,
DateTime asOf)
DateTime asOf,
string? nativeLanguage = null)
{
var target = ApplicantWeeks.Id(asOf);
if (target <= Week)
@@ -42,7 +44,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
var pool = this;
for (var week = Week + 1; week <= target; week++)
{
pool = Step(pool, catalog, roster, schoolSeed, nameSetId, asOf, week);
pool = Step(pool, catalog, roster, schoolSeed, nameSetId, asOf, week, nativeLanguage);
}
return pool;
@@ -93,7 +95,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
int schoolSeed,
string nameSetId,
DateTime asOf,
int week)
int week,
string? nativeLanguage)
{
var rules = RequireRules(catalog);
var rng = new Random(Seed.Mix(schoolSeed, week, Seed.ApplicantSalt));
@@ -113,7 +116,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
nameSetId,
asOf,
new ApplicantPool(week, current.NextIndex, staying),
rng);
rng,
nativeLanguage);
}
private static ApplicantPool Fill(
@@ -123,7 +127,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
string nameSetId,
DateTime asOf,
ApplicantPool current,
Random? rng = null)
Random? rng = null,
string? nativeLanguage = null)
{
var rules = RequireRules(catalog);
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
@@ -131,6 +136,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
rng ??= new Random(Seed.Mix(schoolSeed, current.Week, Seed.ApplicantSalt));
var applicants = new List<Applicant>(rules.PoolSize);
var taken = new HashSet<string>(StringComparer.Ordinal);
@@ -163,7 +170,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
}
else
{
var (_, person) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextIndex, asOf);
var (_, person) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextIndex, asOf, nativeLanguage: native);
nextIndex++;
taken.Add(person.Id);
next = new Applicant(person, HourlyAsk(catalog, person));
+38 -14
View File
@@ -15,7 +15,8 @@ internal static class FamilyFactory
int schoolSeed,
FamilyPlan plan,
DateTime yearStart,
DateTime asOf)
DateTime asOf,
string? nativeLanguage = null)
{
var rng = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.AppearanceSalt));
var familyId = $"f{plan.FamilyIndex}";
@@ -89,7 +90,8 @@ internal static class FamilyFactory
surname,
fatherGiven,
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
isParent: childDrafts.Length > 0));
isParent: childDrafts.Length > 0,
nativeLanguage));
}
if (hasMother)
@@ -108,7 +110,8 @@ internal static class FamilyFactory
surname,
motherGiven,
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
isParent: childDrafts.Length > 0));
isParent: childDrafts.Length > 0,
nativeLanguage));
}
var childIds = new List<string>(childDrafts.Length);
@@ -127,7 +130,8 @@ internal static class FamilyFactory
draft,
asOf,
surname,
fatherGiven.Form));
fatherGiven.Form,
nativeLanguage));
}
var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male);
@@ -146,7 +150,8 @@ internal static class FamilyFactory
PupilSeat seat,
DateTime yearStart,
DateTime asOf,
int childIndex)
int childIndex,
string? nativeLanguage = null)
{
var fatherGiven = FatherGivenOf(family, members);
if (fatherGiven is null)
@@ -169,7 +174,7 @@ internal static class FamilyFactory
var (surnameNom, surnameCases) = SurnameFor(family, names, members, female);
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
var traits = PersonSampler.Traits(catalog, rng, [PersonRoles.Student], age);
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
var skills = PersonSampler.Skills(catalog, names, rng, age, choices, traits, seat.Year, adult: false, nativeLanguage);
var needs = PersonSampler.Needs(catalog);
var name = new PersonName(
given.Form,
@@ -209,7 +214,8 @@ internal static class FamilyFactory
int schoolSeed,
int familyIndex,
DateTime asOf,
string idPrefix = "a")
string idPrefix = "a",
string? nativeLanguage = null)
{
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt));
var familyId = $"{idPrefix}{familyIndex}";
@@ -232,7 +238,8 @@ internal static class FamilyFactory
surname,
given,
NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule),
isParent: false);
isParent: false,
nativeLanguage);
return (new Family(familyId, [id], [], NextChild: 0, FatherGiven: string.Empty, Surname: surname.Male), person);
}
@@ -249,7 +256,8 @@ internal static class FamilyFactory
SurnameEntry surname,
GivenNameEntry given,
string patronymic,
bool isParent)
bool isParent,
string? nativeLanguage)
{
var age = SchoolYears.AgeYears(birth, asOf);
var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff };
@@ -269,7 +277,9 @@ internal static class FamilyFactory
roles,
isStudent: false,
isParent,
classId: null);
classId: null,
pupilYear: null,
nativeLanguage);
}
private static Person RollChild(
@@ -281,7 +291,8 @@ internal static class FamilyFactory
ChildDraft draft,
DateTime asOf,
SurnameEntry surname,
string fatherGiven)
string fatherGiven,
string? nativeLanguage)
{
var age = SchoolYears.AgeYears(draft.Birth, asOf);
var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule);
@@ -301,7 +312,9 @@ internal static class FamilyFactory
[PersonRoles.Student],
isStudent: true,
isParent: false,
draft.Seat.ClassId);
draft.Seat.ClassId,
draft.Seat.Year,
nativeLanguage);
}
private static Person FinishPerson(
@@ -320,11 +333,22 @@ internal static class FamilyFactory
IReadOnlyList<string> roles,
bool isStudent,
bool isParent,
string? classId)
string? classId,
int? pupilYear,
string? nativeLanguage)
{
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
var traits = PersonSampler.Traits(catalog, rng, roles, age);
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
var skills = PersonSampler.Skills(
catalog,
names,
rng,
age,
choices,
traits,
isStudent ? pupilYear : null,
adult: !isStudent,
nativeLanguage);
var needs = PersonSampler.Needs(catalog);
var name = new PersonName(
given.Form,
+38
View File
@@ -0,0 +1,38 @@
namespace HSchool.People;
/// <summary>
/// A name set can speak several native languages; the school picks one. Empty request rolls from
/// the school seed so the same create always lands on the same tongue.
/// </summary>
public static class NativeLanguages
{
public static bool Allows(NameSetDef names, string skill) =>
names.Spoken.Contains(skill, StringComparer.Ordinal);
/// <summary>
/// <paramref name="requested"/> must already be in the set, or empty. Invalid ids return
/// null so the create path can 400. When omitted, a new school rolls; a reload without a
/// saved pick takes the first listed language so old saves do not reshuffle intake.
/// </summary>
public static string? Pick(NameSetDef names, int schoolSeed, string? requested, bool rollIfOmitted)
{
var spoken = names.Spoken;
if (spoken.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(requested))
{
return Allows(names, requested) ? requested : null;
}
if (!rollIfOmitted || spoken.Count == 1)
{
return spoken[0];
}
var rng = new Random(Seed.ForSchool(schoolSeed, Seed.NativeLanguageSalt));
return spoken[rng.Next(spoken.Count)];
}
}
+258 -12
View File
@@ -35,28 +35,275 @@ internal static class PersonSampler
public static Dictionary<string, int> Skills(
DefCatalog catalog,
NameSetDef? names,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
IReadOnlyList<string> traits)
IReadOnlyList<string> traits,
int? pupilYear,
bool adult,
string? nativeLanguage = null)
{
var values = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (skill.Abstract)
if (!skill.Abstract && skill.Always)
{
RollInto(values, rng, age, choices, skill);
}
}
GrantNative(values, catalog, NativeOf(names, nativeLanguage), rng, age, choices);
if (pupilYear is { } year)
{
GrantYearSubjects(values, catalog, rng, age, choices, year);
}
if (adult)
{
GrantAdultExtras(values, catalog, rng, age, choices);
}
ApplyTraitsAndBody(values, catalog, choices, traits, values.Keys.ToArray());
// After traits so Diligent cannot push a related tongue past the "knows a bit" cap.
GrantRelated(values, catalog, names, NativeOf(names, nativeLanguage), rng, choices);
return values;
}
/// <summary>
/// Adds subject skills a pupil just became old enough for. Existing values stay put — yearly
/// intake must not re-roll mathematics every September.
/// </summary>
public static Dictionary<string, int> EnsurePupilYear(
DefCatalog catalog,
NameSetDef? names,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
IReadOnlyList<string> traits,
IReadOnlyDictionary<string, int> existing,
int pupilYear,
string? nativeLanguage = null)
{
var values = new Dictionary<string, int>(existing, StringComparer.Ordinal);
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (skill.Abstract || !skill.Always || values.ContainsKey(skill.DefName))
{
continue;
}
var mean = MeanForAge(skill, age);
var stdDev = skill.Distribution?.StdDev ?? 10f;
var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
rolled = Clamp(rolled, skill.Range);
rolled = ApplyBodyLimits(rolled, skill, choices);
rolled = Clamp(rolled, skill.Range);
values[skill.DefName] = rolled;
RollInto(values, rng, age, choices, skill);
}
GrantNative(values, catalog, NativeOf(names, nativeLanguage), rng, age, choices);
GrantYearSubjects(values, catalog, rng, age, choices, pupilYear);
var added = values.Keys.Where(name => !existing.ContainsKey(name)).ToArray();
ApplyTraitsAndBody(values, catalog, choices, traits, added);
return values;
}
private static string? NativeOf(NameSetDef? names, string? nativeLanguage)
{
if (!string.IsNullOrWhiteSpace(nativeLanguage))
{
return nativeLanguage;
}
var spoken = names?.Spoken;
return spoken is { Count: > 0 } ? spoken[0] : null;
}
private static void GrantNative(
Dictionary<string, int> values,
DefCatalog catalog,
string? native,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices)
{
if (string.IsNullOrWhiteSpace(native)
|| !catalog.Skills.TryGetValue(native, out var skill)
|| skill.Abstract)
{
return;
}
RollInto(values, rng, age, choices, skill);
}
/// <summary>
/// Other tongues of the same name set, at a low level. Skips keys already granted as native
/// or by a subject — a Belarusian pupil who studies Russian keeps the school value.
/// </summary>
private static void GrantRelated(
Dictionary<string, int> values,
DefCatalog catalog,
NameSetDef? names,
string? native,
Random rng,
IReadOnlyDictionary<string, string> choices)
{
if (names is null || names.RelatedLanguageChance <= 0)
{
return;
}
foreach (var name in names.Spoken.OrderBy(candidate => candidate, StringComparer.Ordinal))
{
if (name.Equals(native, StringComparison.Ordinal)
|| values.ContainsKey(name)
|| !catalog.Skills.TryGetValue(name, out var skill)
|| skill.Abstract)
{
continue;
}
if (rng.NextDouble() >= names.RelatedLanguageChance)
{
continue;
}
var rolled = (int)Math.Round(names.RelatedLanguageMean + (names.RelatedLanguageStdDev * NextGaussian(rng)));
rolled = Clamp(rolled, skill.Range);
rolled = Math.Min(rolled, names.RelatedLanguageMax);
rolled = ApplyBodyLimits(rolled, skill, choices);
values[skill.DefName] = Clamp(rolled, skill.Range);
}
}
private static void GrantYearSubjects(
Dictionary<string, int> values,
DefCatalog catalog,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
int pupilYear)
{
foreach (var subject in catalog.Subjects.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
{
if (subject.Abstract || pupilYear < subject.Grades.Min || pupilYear > subject.Grades.Max)
{
continue;
}
foreach (var share in subject.Skills.OrderBy(row => row.Skill, StringComparer.Ordinal))
{
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
{
continue;
}
RollInto(values, rng, age, choices, skill);
}
}
}
private static void GrantAdultExtras(
Dictionary<string, int> values,
DefCatalog catalog,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices)
{
var academic = catalog.Skills.Values
.Where(skill =>
!skill.Abstract
&& !skill.Always
&& !skill.Work
&& skill.AdultChance <= 0
&& SubjectUses(catalog, skill.DefName))
.OrderBy(skill => skill.DefName, StringComparer.Ordinal)
.ToList();
PickInto(values, rng, age, choices, academic, rng.Next(3));
var work = catalog.Skills.Values
.Where(skill => !skill.Abstract && skill.Work)
.OrderBy(skill => skill.DefName, StringComparer.Ordinal)
.ToList();
PickInto(values, rng, age, choices, work, rng.Next(3));
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (skill.Abstract || skill.AdultChance <= 0 || values.ContainsKey(skill.DefName))
{
continue;
}
if (rng.NextDouble() < skill.AdultChance)
{
RollInto(values, rng, age, choices, skill);
}
}
}
private static bool SubjectUses(DefCatalog catalog, string skillName)
{
foreach (var subject in catalog.Subjects.Values)
{
if (subject.Abstract)
{
continue;
}
foreach (var share in subject.Skills)
{
if (share.Skill.Equals(skillName, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
private static void PickInto(
Dictionary<string, int> values,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
List<SkillDef> pool,
int count)
{
var remaining = pool.Where(skill => !values.ContainsKey(skill.DefName)).ToList();
for (var n = 0; n < count && remaining.Count > 0; n++)
{
var index = rng.Next(remaining.Count);
RollInto(values, rng, age, choices, remaining[index]);
remaining.RemoveAt(index);
}
}
private static void RollInto(
Dictionary<string, int> values,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
SkillDef skill)
{
if (values.ContainsKey(skill.DefName))
{
return;
}
var mean = MeanForAge(skill, age);
var stdDev = skill.Distribution?.StdDev ?? 10f;
var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
rolled = Clamp(rolled, skill.Range);
rolled = ApplyBodyLimits(rolled, skill, choices);
values[skill.DefName] = Clamp(rolled, skill.Range);
}
private static void ApplyTraitsAndBody(
Dictionary<string, int> values,
DefCatalog catalog,
IReadOnlyDictionary<string, string> choices,
IReadOnlyList<string> traits,
IEnumerable<string> names)
{
var allowed = names as ISet<string> ?? new HashSet<string>(names, StringComparer.Ordinal);
var touched = new HashSet<string>(StringComparer.Ordinal);
foreach (var traitName in traits)
{
@@ -67,7 +314,8 @@ internal static class PersonSampler
foreach (var modifier in trait.SkillModifiers)
{
if (!values.TryGetValue(modifier.Skill, out var current)
if (!allowed.Contains(modifier.Skill)
|| !values.TryGetValue(modifier.Skill, out var current)
|| !catalog.Skills.TryGetValue(modifier.Skill, out var skill))
{
continue;
@@ -86,8 +334,6 @@ internal static class PersonSampler
var skill = catalog.Skills[skillName];
values[skillName] = Clamp(ApplyBodyLimits(values[skillName], skill, choices), skill.Range);
}
return values;
}
public static List<string> Traits(DefCatalog catalog, Random rng, IReadOnlyList<string> roles, int age)
+4 -2
View File
@@ -16,7 +16,8 @@ public static class RosterGenerator
MapLayout map,
int schoolSeed,
string nameSetId,
DateTime? asOf = null)
DateTime? asOf = null,
string? nativeLanguage = null)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
@@ -27,6 +28,7 @@ public static class RosterGenerator
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: true);
var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc);
var yearStart = SchoolYears.StartOn(when);
var demand = SchoolDemand.From(catalog, map);
@@ -36,7 +38,7 @@ public static class RosterGenerator
var families = new List<Family>(plans.Count);
foreach (var plan in plans)
{
var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when);
var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when, native);
families.Add(family);
people.AddRange(members);
}
+2
View File
@@ -13,6 +13,8 @@ public static class Seed
public const int HouseholdSalt = 5;
public const int ApplicantSalt = 6;
public const int CommuteSalt = 7;
public const int SkillGrantSalt = 8;
public const int NativeLanguageSalt = 9;
/// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
+56 -5
View File
@@ -39,7 +39,8 @@ public static class YearlyIntake
Roster roster,
int schoolSeed,
string nameSetId,
DateTime asOf)
DateTime asOf,
string? nativeLanguage = null)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(roster);
@@ -50,6 +51,8 @@ public static class YearlyIntake
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
if (roster.Classes.Count == 0)
{
return roster;
@@ -107,6 +110,10 @@ public static class YearlyIntake
var classes = new SchoolClass[roster.Classes.Count];
var newSeats = new List<PupilSeat>();
var incumbents = remainingPeople
.Where(pair => pair.Value.IsStudent)
.Select(pair => pair.Key)
.ToHashSet(StringComparer.Ordinal);
for (var i = 0; i < roster.Classes.Count; i++)
{
var schoolClass = roster.Classes[i];
@@ -134,13 +141,56 @@ public static class YearlyIntake
families,
remainingPeople,
newSeats,
nextFamilyIndex: roster.Families.Select(family => IndexOf(family.Id)).DefaultIfEmpty(-1).Max() + 1);
nextFamilyIndex: roster.Families.Select(family => IndexOf(family.Id)).DefaultIfEmpty(-1).Max() + 1,
native);
GrantPromotedSkills(catalog, names, schoolSeed, when, remainingPeople, classes, incumbents, native);
var people = remainingPeople.Values.ToList();
var filled = AttachPupils(classes, people);
return new Roster(people, families, filled);
}
private static void GrantPromotedSkills(
DefCatalog catalog,
NameSetDef names,
int schoolSeed,
DateTime when,
Dictionary<string, Person> people,
SchoolClass[] classes,
HashSet<string> incumbents,
string? nativeLanguage)
{
var yearByClass = classes.ToDictionary(
schoolClass => schoolClass.Id,
schoolClass => schoolClass.Year,
StringComparer.Ordinal);
foreach (var id in incumbents)
{
if (!people.TryGetValue(id, out var person)
|| person.ClassId is not { } classId
|| !yearByClass.TryGetValue(classId, out var year))
{
continue;
}
var rng = new Random(Seed.Mix(schoolSeed, person.Id, when.Year, Seed.SkillGrantSalt));
people[id] = person with
{
Skills = PersonSampler.EnsurePupilYear(
catalog,
names,
rng,
person.AgeOn(when),
person.Choices,
person.Traits,
person.Skills,
year,
nativeLanguage),
};
}
}
private static void FillFirstYear(
DefCatalog catalog,
NameSetDef names,
@@ -150,7 +200,8 @@ public static class YearlyIntake
List<Family> families,
Dictionary<string, Person> people,
List<PupilSeat> seats,
int nextFamilyIndex)
int nextFamilyIndex,
string? nativeLanguage)
{
var cursor = 0;
foreach (var family in families.ToArray())
@@ -185,7 +236,7 @@ public static class YearlyIntake
var childIndex = family.NextChildIndex;
var rng = new Random(Seed.Mix(schoolSeed, index, Seed.IntakeSalt + yearStart.Year * 10 + childIndex));
var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex);
var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex, nativeLanguage);
people[child.Id] = child;
var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal));
families[familyAt] = family with
@@ -209,7 +260,7 @@ public static class YearlyIntake
var leftover = seats.Skip(cursor).ToArray();
foreach (var plan in FamilyPlanner.Singletons(leftover, Math.Max(nextFamilyIndex, 0)))
{
var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf);
var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf, nativeLanguage);
families.Add(created);
foreach (var member in members)
{
+23 -2
View File
@@ -86,7 +86,7 @@ internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
IReadOnlyList<NameSetInfoResponse> NameSets,
IReadOnlyList<SubjectInfoResponse> Subjects,
DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays,
@@ -99,7 +99,7 @@ internal sealed record CatalogResponse(
Placeable(catalog.Floors.Values, catalog, locale),
PlaceableRooms(catalog, locale),
PlaceableThings(catalog, locale),
Placeable(catalog.NameSets.Values, catalog, locale),
PlaceableNameSets(catalog, locale),
PlaceableSubjects(catalog, locale),
MapDayFrame(catalog, locale),
PlaceableHolidays(catalog, locale),
@@ -113,6 +113,22 @@ internal sealed record CatalogResponse(
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
.ToArray();
private static IReadOnlyList<NameSetInfoResponse> PlaceableNameSets(DefCatalog catalog, string locale) =>
catalog.NameSets.Values
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new NameSetInfoResponse(
def.DefName,
catalog.Label(locale, def),
def.Spoken
.Select(skill => new DefInfoResponse(
skill,
catalog.Skills.TryGetValue(skill, out var language)
? catalog.Label(locale, language)
: skill))
.ToArray()))
.ToArray();
private static IReadOnlyList<DefInfoResponse> PlaceableThings(DefCatalog catalog, string locale) =>
catalog.Things.Values
.Where(def => !def.Abstract)
@@ -178,6 +194,11 @@ internal sealed record CatalogResponse(
internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
internal sealed record NameSetInfoResponse(
string DefName,
string Label,
IReadOnlyList<DefInfoResponse> NativeLanguages);
internal sealed record RoomInfoResponse(
string DefName,
string Label,
+16 -4
View File
@@ -206,7 +206,8 @@ internal sealed record ApplicantResponse(
bool IsParent,
float HourlyWageAsk,
float MonthlyBase,
IReadOnlyList<LabeledStatResponse> Skills);
IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits);
internal sealed record StaffMemberResponse(
string Id,
@@ -257,7 +258,8 @@ internal static class StaffingMapper
applicant.Person.IsParent,
applicant.HourlyWageAsk,
rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk),
StrongSkills(applicant.Person, catalog, locale)))
SkillsOf(applicant.Person, catalog, locale),
TraitsOf(applicant.Person, catalog, locale)))
.ToArray();
var staff = roster.People
@@ -290,12 +292,11 @@ internal static class StaffingMapper
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
}
private static IReadOnlyList<LabeledStatResponse> StrongSkills(Person person, DefCatalog? catalog, string locale)
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
{
return person.Skills
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Take(3)
.Select(pair => new LabeledStatResponse(
pair.Key,
catalog is not null && catalog.Skills.TryGetValue(pair.Key, out var def)
@@ -305,6 +306,17 @@ internal static class StaffingMapper
.ToArray();
}
private static IReadOnlyList<DefLabelResponse> TraitsOf(Person person, DefCatalog? catalog, string locale)
{
return person.Traits
.Select(name => new DefLabelResponse(
name,
catalog is not null && catalog.Traits.TryGetValue(name, out var def)
? catalog.Label(locale, def)
: name))
.ToArray();
}
private static StaffMemberResponse Member(
Person person,
Roster roster,
+5 -1
View File
@@ -54,6 +54,7 @@ internal static class SchoolEndpoints
request.ModIds,
request.Map,
request.NameSetId,
request.NativeLanguage,
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
@@ -77,6 +78,8 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
SchoolCreationError.UnknownNameSet =>
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
SchoolCreationError.UnknownNativeLanguage =>
Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that name set."),
_ => Results.Problem("Unknown error."),
};
})
@@ -486,7 +489,8 @@ internal sealed record CreateSchoolRequest(
DateTime StartDate,
IReadOnlyList<string>? ModIds,
MapLayout? Map,
string? NameSetId);
string? NameSetId,
string? NativeLanguage);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
+1
View File
@@ -17,6 +17,7 @@ internal abstract record GameCommand
IReadOnlyList<string>? ExtraModIds,
MapLayout? Map,
string? NameSetId,
string? NativeLanguage,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
+17 -1
View File
@@ -328,10 +328,23 @@ internal sealed class GameLoopService(
return;
}
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet));
return;
}
if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(names, command.NativeLanguage))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage));
return;
}
var id = _nextId++;
store.WriteNextId(_nextId);
var nativeLanguage = NativeLanguages.Pick(names, id, command.NativeLanguage, rollIfOmitted: true);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage);
Track(worker);
worker.Start();
@@ -499,6 +512,7 @@ internal sealed class GameLoopService(
save.ModIds,
save.Map,
save.NameSetId,
save.NativeLanguage,
save.Presence);
worker.Start();
@@ -547,6 +561,7 @@ internal sealed class GameLoopService(
IReadOnlyList<string>? modIds,
MapLayout? map,
string? nameSetId,
string? nativeLanguage,
IReadOnlyList<PresenceSnapshot>? presence = null) =>
new(
id,
@@ -558,6 +573,7 @@ internal sealed class GameLoopService(
modIds,
map,
nameSetId,
nativeLanguage,
presence,
_options,
clients,
+3
View File
@@ -28,6 +28,8 @@ internal sealed class SchoolSave
public string? NameSetId { get; init; }
public string? NativeLanguage { get; init; }
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
}
@@ -162,6 +164,7 @@ internal sealed class SchoolStore
ModIds = save.ModIds,
Map = save.Map,
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
});
}
+32 -6
View File
@@ -33,6 +33,7 @@ internal sealed class SchoolWorker
private readonly IReadOnlyList<string>? _modIds;
private readonly MapLayout? _savedMap;
private readonly string? _nameSetId;
private string? _nativeLanguage;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly Action<int> _onFailed;
@@ -65,6 +66,7 @@ internal sealed class SchoolWorker
IReadOnlyList<string>? modIds,
MapLayout? savedMap,
string? nameSetId,
string? nativeLanguage,
IReadOnlyList<PresenceSnapshot>? savedPresence,
SimulationOptions options,
ClientRegistry clients,
@@ -83,6 +85,7 @@ internal sealed class SchoolWorker
_modIds = modIds;
_savedMap = savedMap;
_nameSetId = nameSetId;
_nativeLanguage = nativeLanguage;
_savedPresence = savedPresence;
_options = options;
_clients = clients;
@@ -849,12 +852,15 @@ internal sealed class SchoolWorker
ApplicantPool applicants;
int seed;
var generated = false;
string? native;
if (_isNew)
{
seed = school.Id;
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
_nativeLanguage = native;
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
generated = true;
}
else
@@ -863,13 +869,17 @@ internal sealed class SchoolWorker
if (loaded is null)
{
seed = school.Id;
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
_nativeLanguage = native;
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
generated = true;
}
else
{
seed = loaded.Seed;
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: false);
_nativeLanguage = native;
roster = loaded.ToRoster();
if (loaded.Applicants is { Applicants.Count: > 0 })
{
@@ -877,7 +887,7 @@ internal sealed class SchoolWorker
}
else
{
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
generated = true;
}
}
@@ -889,7 +899,7 @@ internal sealed class SchoolWorker
$"School {_id} roster does not match its map; the people file was left untouched.");
}
school.InstallPeople(roster, seed, nameSetId, applicants);
school.InstallPeople(roster, seed, nameSetId, applicants, _nativeLanguage);
InstallTimetable(school);
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
school.RestorePresence(_savedPresence);
@@ -916,6 +926,21 @@ internal sealed class SchoolWorker
return available.Contains(requested, StringComparer.Ordinal) ? requested : null;
}
private static string? ResolveNative(
DefCatalog catalog,
string nameSetId,
int schoolSeed,
string? requested,
bool generating)
{
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
return null;
}
return NativeLanguages.Pick(names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested));
}
private void Persist()
{
var school = _school;
@@ -938,6 +963,7 @@ internal sealed class SchoolWorker
ModIds = school.Catalog?.PackIds,
Map = school.Map,
NameSetId = _nameSetId,
NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(),
});
}
@@ -3,6 +3,11 @@
"patronymicRule": "slavic",
"defaultGivenDeclension": "hard",
"defaultSurnameDeclension": "ov",
"nativeLanguages": ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
"relatedLanguageChance": 0.6,
"relatedLanguageMean": 22,
"relatedLanguageStdDev": 8,
"relatedLanguageMax": 40,
"maleGiven": [
{ "form": "Александр" },
{ "form": "Алексей" },
@@ -61,9 +61,4 @@
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 16 },
},
{
"defName": "ForeignLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 15 },
},
]
@@ -0,0 +1,40 @@
[
{
"defName": "English",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 15 },
"adultChance": 0.4,
"ageMeans": [
{ "age": 11, "mean": 28 },
{ "age": 17, "mean": 50 },
],
},
{
"defName": "German",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 42, "stdDev": 14 },
"adultChance": 0.08,
"ageMeans": [
{ "age": 14, "mean": 24 },
{ "age": 17, "mean": 42 },
],
},
{
"defName": "BelarusianLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 52, "stdDev": 13 },
"ageMeans": [
{ "age": 7, "mean": 32 },
{ "age": 17, "mean": 56 },
],
},
{
"defName": "UkrainianLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 52, "stdDev": 13 },
"ageMeans": [
{ "age": 7, "mean": 32 },
{ "age": 17, "mean": 56 },
],
},
]
@@ -1,6 +1,7 @@
[
{
"defName": "Agility",
"always": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"bodyLimits": [
@@ -11,6 +12,7 @@
},
{
"defName": "Strength",
"always": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"bodyLimits": [
@@ -20,6 +22,7 @@
},
{
"defName": "Endurance",
"always": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
"bodyLimits": [
@@ -0,0 +1,13 @@
[
{
"defName": "Communication",
"always": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 52, "stdDev": 12 },
"ageMeans": [
{ "age": 7, "mean": 32 },
{ "age": 17, "mean": 52 },
{ "age": 40, "mean": 58 },
],
},
]
@@ -0,0 +1,52 @@
[
{
"defName": "Pedagogy",
"work": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 14 },
"ageMeans": [
{ "age": 22, "mean": 40 },
{ "age": 45, "mean": 58 },
],
},
{
"defName": "Medicine",
"work": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 46, "stdDev": 15 },
"ageMeans": [
{ "age": 22, "mean": 38 },
{ "age": 45, "mean": 56 },
],
},
{
"defName": "Cooking",
"work": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"ageMeans": [
{ "age": 20, "mean": 42 },
{ "age": 45, "mean": 58 },
],
},
{
"defName": "Administration",
"work": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 13 },
"ageMeans": [
{ "age": 22, "mean": 40 },
{ "age": 45, "mean": 57 },
],
},
{
"defName": "Librarianship",
"work": true,
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 46, "stdDev": 13 },
"ageMeans": [
{ "age": 22, "mean": 38 },
{ "age": 45, "mean": 55 },
],
},
]
@@ -1,6 +1,6 @@
{
"defName": "Staffing",
"poolSize": 12,
"poolSize": 32,
"stayChance": 0.65,
"parentChance": 0.35,
"hourlyWageBase": 30,
@@ -32,7 +32,7 @@
"defName": "ForeignLanguage",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "ForeignLanguage", "share": 1 }],
"skills": [{ "skill": "English", "share": 1 }],
},
{
"defName": "History",
@@ -62,6 +62,16 @@
"Geography": "Geography",
"Informatics": "Informatics",
"ForeignLanguage": "Foreign language",
"English": "English",
"German": "German",
"BelarusianLanguage": "Belarusian",
"UkrainianLanguage": "Ukrainian",
"Communication": "Communication",
"Pedagogy": "Pedagogy",
"Medicine": "Medicine",
"Cooking": "Cooking",
"Administration": "Administration",
"Librarianship": "Librarianship",
"Agility": "Agility",
"Strength": "Strength",
"Endurance": "Endurance",
@@ -62,6 +62,16 @@
"Geography": "География",
"Informatics": "Информатика",
"ForeignLanguage": "Иностранный язык",
"English": "Английский",
"German": "Немецкий",
"BelarusianLanguage": "Белорусский",
"UkrainianLanguage": "Украинский",
"Communication": "Общение",
"Pedagogy": "Педагогика",
"Medicine": "Медицина",
"Cooking": "Готовка",
"Administration": "Делопроизводство",
"Librarianship": "Библиотечное дело",
"Agility": "Ловкость",
"Strength": "Сила",
"Endurance": "Выносливость",
@@ -65,13 +65,16 @@ internal static class LessonLearningSystem
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
foreach (var share in subject.Skills)
{
if (!catalog.Skills.TryGetValue(share.Skill, out var skill)
|| skill.Abstract
|| !skills.Values.TryGetValue(share.Skill, out var current))
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
{
continue;
}
if (!skills.Values.TryGetValue(share.Skill, out var current))
{
current = skill.Range.Min;
}
skills.Values[share.Skill] = LessonLearning.Gain(
current,
skill,
+7 -3
View File
@@ -78,6 +78,9 @@ public sealed class School : IDisposable
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
public string? NameSetId { get; private set; }
/// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; }
/// <summary>Last built table. Null until the worker installs people.</summary>
public Timetable? Timetable { get; private set; }
@@ -115,7 +118,7 @@ public sealed class School : IDisposable
/// <summary>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary>
public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null)
public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null, string? nativeLanguage = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(roster);
@@ -123,6 +126,7 @@ public sealed class School : IDisposable
Roster = roster;
PeopleSeed = seed;
NameSetId = nameSetId;
NativeLanguage = nativeLanguage;
Applicants = applicants;
RosterSpawner.Spawn(World, roster);
PlanDay = null;
@@ -282,7 +286,7 @@ public sealed class School : IDisposable
var changed = false;
foreach (var date in YearlyIntake.DatesBetween(before, after))
{
Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date);
Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date, NativeLanguage);
changed = true;
}
@@ -304,7 +308,7 @@ public sealed class School : IDisposable
return false;
}
var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time);
var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time, NativeLanguage);
if (next.Week == Applicants.Week)
{
return false;
+1
View File
@@ -11,6 +11,7 @@ public enum SchoolCreationError
UnknownMod,
InvalidCatalog,
UnknownNameSet,
UnknownNativeLanguage,
}
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>