Dress people at generation from a separate apparel stream.

Wardrobes live on the person in people.json: everyday layers, textbooks per parallel, and chance-based bags from catalog data. Hauling and the carry cap are defs, not constants. The golden roster gains an items column; later family members shift because Hauling consumes extra appearance rolls. Old saves without items are dressed on load.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 03:24:54 +03:00
co-authored by Cursor
parent d8f4958167
commit 37501c506a
43 changed files with 8883 additions and 1305 deletions
@@ -26,6 +26,7 @@ describe('t', () => {
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
expect(t('staffUncoveredShort', { label: 'Начальные классы', min: 1, max: 4, n: 3 }))
.toBe('Начальные классы (1–4) — 3 short');
expect(t('peopleCarryMass', { held: '1.2', cap: '14' })).toBe('1.2 / 14 kg');
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
.toBe('Кабинет 204 (Математика · 5Б)');
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
+6
View File
@@ -128,6 +128,9 @@ const ru = {
peopleSkills: 'Навыки',
peopleTraits: 'Черты',
peopleNeeds: 'Нужды',
peopleApparel: 'Одежда',
peopleCarry: 'Ноша',
peopleCarryMass: '{held} / {cap} кг',
peopleFamily: 'Семья',
peopleParents: 'Родители',
peopleChildren: 'Дети',
@@ -345,6 +348,9 @@ const en: Messages = {
peopleSkills: 'Skills',
peopleTraits: 'Traits',
peopleNeeds: 'Needs',
peopleApparel: 'Clothes',
peopleCarry: 'Carried',
peopleCarryMass: '{held} / {cap} kg',
peopleFamily: 'Family',
peopleParents: 'Parents',
peopleChildren: 'Children',
+22
View File
@@ -291,6 +291,28 @@ export interface PersonCard {
readonly siblings: readonly PersonRel[];
readonly partners: readonly PersonRel[];
};
readonly worn: readonly WornItem[];
readonly carried: readonly CarriedItem[];
readonly carryMass: number;
readonly carryCapacity: number;
}
export interface WornItem {
readonly defName: string;
readonly label: string;
readonly color: string | null;
readonly colorLabel: string | null;
readonly layers: readonly DefLabel[];
}
export interface CarriedItem {
readonly defName: string;
readonly label: string;
readonly color: string | null;
readonly colorLabel: string | null;
readonly subject: string | null;
readonly subjectLabel: string | null;
readonly mass: number;
}
export async function fetchPeople(schoolId: number, query: PeopleQuery, lang: string): Promise<PeoplePage> {
+5
View File
@@ -644,6 +644,11 @@ body {
font-size: 12px;
}
.people__carry-mass {
margin: 0 0 4px;
font-size: 12px;
}
.people__pair {
display: flex;
align-items: baseline;
@@ -55,6 +55,10 @@ function personCard(): PersonCard {
activity: null,
activityLabel: null,
family: { parents: [], children: [], siblings: [], partners: [] },
worn: [],
carried: [],
carryMass: 0,
carryCapacity: 0,
};
}
@@ -0,0 +1,77 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import type { PersonCard } from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { renderPersonCard } from './personCard.ts';
const initial = getLocale();
afterEach(() => setLocale(initial));
function card(overrides: Partial<PersonCard> = {}): PersonCard {
return {
id: 'f0.c0',
fullName: 'Иванова Мария',
surname: 'Иванова',
given: 'Мария',
patronymic: '',
female: true,
age: 12,
birthDate: '2000-03-14',
roles: ['student'],
classYear: 5,
classLetter: 'А',
classId: 'class-1',
position: null,
positionLabel: null,
body: [],
skills: [],
traits: [],
needs: [],
activity: null,
activityLabel: null,
family: { parents: [], children: [], siblings: [], partners: [] },
worn: [
{
defName: 'Shirt',
label: 'Рубашка',
color: 'White',
colorLabel: 'Белый',
layers: [{ defName: 'Top', label: 'Верх' }],
},
],
carried: [
{
defName: 'Textbook',
label: 'Учебник',
color: null,
colorLabel: null,
subject: 'Mathematics',
subjectLabel: 'Математика',
mass: 0.4,
},
],
carryMass: 1.2,
carryCapacity: 14,
...overrides,
};
}
describe('renderPersonCard', () => {
it('shows worn layers with colour and carried mass in the same scroll', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {});
expect(root.textContent).toContain(t('peopleApparel'));
expect(root.textContent).toContain('Верх');
expect(root.textContent).toContain('Рубашка · Белый');
expect(root.textContent).toContain(t('peopleCarry'));
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
expect(root.textContent).toContain('Учебник · Математика');
expect(root.querySelector('.people__tabs')).toBeNull();
});
});
+54
View File
@@ -47,6 +47,8 @@ export function renderPersonCard(
appendPairs(parent, t('peopleSkills'), card.skills);
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
appendNeeds(parent, t('peopleNeeds'), card.needs);
appendApparel(parent, card.worn);
appendCarry(parent, card);
const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
@@ -112,6 +114,58 @@ function appendPairs(
parent.append(section(title), grid);
}
function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
if (rows.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of rows) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: layers.length > 0 ? layers : row.label }),
el('dd', { text: value }),
),
);
}
parent.append(section(t('peopleApparel')), grid);
}
function appendCarry(parent: HTMLElement, card: PersonCard): void {
const mass = el('p', {
class: 'people__carry-mass',
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
});
const grid = el('dl', { class: 'people__pairs' });
for (const row of card.carried) {
const extra = row.subjectLabel ?? row.colorLabel;
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: value }),
el('dd', { text: formatMass(row.mass) }),
),
);
}
parent.append(section(t('peopleCarry')), mass);
if (card.carried.length > 0) {
parent.append(grid);
}
}
function formatMass(value: number): string {
return String(Math.round(value * 100) / 100);
}
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
if (values.length === 0) {
return;