This commit is contained in:
Leonid Pershin
2026-08-18 23:51:10 +03:00
parent d8f8db6a48
commit 65feda3756
50 changed files with 1485 additions and 224 deletions
@@ -9,8 +9,8 @@ import {
toDateAndTimeInputs,
} from './gameTime.ts';
// The default start of a new school: 3 April 2012, 06:00 — a Tuesday.
const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0));
// The default start of a new school: 31 March 2012, 06:00 — a Saturday.
const START = new Date(Date.UTC(2012, 2, 31, 6, 0, 0));
const initial = getLocale();
beforeEach(() => setLocale('ru'));
@@ -22,25 +22,25 @@ describe('game time formatting', () => {
});
it('shows the weekday of the game date', () => {
expect(formatGameWeekday(START)).toBe('вторник');
expect(formatGameWeekday(START)).toBe('суббота');
});
it('shows the full date', () => {
expect(formatGameDate(START)).toContain('2012');
expect(formatGameDate(START)).toContain('апреля');
expect(formatGameDate(START)).toContain('марта');
});
it('shows a compact date and time for the school cards', () => {
expect(formatGameDateTime(START)).toContain('03.04.2012');
expect(formatGameDateTime(START)).toContain('31.03.2012');
expect(formatGameDateTime(START)).toContain('06:00');
});
it('formats the same instant in English when the locale is en', () => {
setLocale('en');
expect(formatGameWeekday(START)).toBe('Tuesday');
expect(formatGameDate(START)).toContain('April');
expect(formatGameDateTime(START)).toContain('03/04/2012');
expect(formatGameWeekday(START)).toBe('Saturday');
expect(formatGameDate(START)).toContain('March');
expect(formatGameDateTime(START)).toContain('31/03/2012');
expect(formatGameTimeOfDay(START)).toBe('06:00');
});
@@ -55,7 +55,7 @@ describe('game time formatting', () => {
describe('date inputs', () => {
it('splits an instant into the date and time input values', () => {
expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-04-03', time: '06:00' });
expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-03-31', time: '06:00' });
});
it('rebuilds the same instant from those values', () => {
+2
View File
@@ -65,6 +65,7 @@ const ru = {
removeRoom: 'Удалить комнату',
slotEmpty: '— пусто —',
slotCount: 'Количество',
editorSeats: 'Мест',
backToMenu: '← В главное меню',
pause: 'Пауза',
@@ -193,6 +194,7 @@ const en: Messages = {
removeRoom: 'Remove room',
slotEmpty: '— empty —',
slotCount: 'Count',
editorSeats: 'Seats',
backToMenu: '← Main menu',
pause: 'Pause',
+19
View File
@@ -85,10 +85,27 @@ export interface RoomSlotInfo {
export interface RoomInfo {
readonly defName: string;
readonly label: string;
readonly homeroom: boolean;
readonly seatThing?: string | null;
readonly defaultSeats: number;
readonly slots: readonly RoomSlotInfo[];
readonly positions: readonly string[];
}
export interface SubjectSkillShare {
readonly skill: string;
readonly share: number;
}
export interface SubjectInfo {
readonly defName: string;
readonly label: string;
readonly gradeMin: number;
readonly gradeMax: number;
readonly hoursPerWeek: number;
readonly skills: readonly SubjectSkillShare[];
}
export interface MapLayout {
territory: { id: string; def: string };
buildings: { id: string; def: string }[];
@@ -99,6 +116,7 @@ export interface MapLayout {
building: string;
floor: string;
label?: string;
seats?: number;
slots?: { key: string; thing: string; count?: number }[];
}[];
links: { a: string; b: string }[];
@@ -112,6 +130,7 @@ export interface CatalogResponse {
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
readonly nameSets: readonly DefInfo[];
readonly subjects: readonly SubjectInfo[];
}
export async function fetchMods(): Promise<readonly ModInfo[]> {
+116 -5
View File
@@ -532,8 +532,9 @@ body {
}
.people__card-name {
margin: 0 0 4px;
font-size: 16px;
margin: 0 0 2px;
font-size: 15px;
font-weight: 600;
}
.people__card-meta {
@@ -542,12 +543,122 @@ body {
}
.people__section {
margin-top: 12px;
margin-top: 10px;
}
.people__stats {
.people__section-title {
margin: 0 0 3px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/*
* Name on the left, value on the right, as many columns as the panel is wide. As a bulleted list
* fourteen skills ran the card past the fold with three quarters of the width empty.
*/
.people__pairs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0 16px;
margin: 0;
padding-left: 18px;
font-size: 12px;
}
.people__pair {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 1px 0;
border-bottom: 1px solid rgba(42, 50, 66, 0.5);
}
.people__pair dt {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.people__pair dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.people__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.people__tag {
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
}
.people__needs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 6px 16px;
}
.people__need {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 8px;
font-size: 12px;
}
.people__need-label {
color: var(--text-muted);
}
.people__need-value {
font-variant-numeric: tabular-nums;
}
.people__need-track {
grid-column: 1 / -1;
height: 4px;
margin-top: 2px;
border-radius: 999px;
background: var(--surface);
overflow: hidden;
}
.people__need-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
/* An empty need is the one worth spotting without reading the number. */
.people__need-fill--low {
background: var(--danger);
}
.people__rel {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 8px;
font-size: 12px;
}
.people__rel-title {
color: var(--text-muted);
}
.people__rel-list {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
}
.people__link {
+67 -39
View File
@@ -103,52 +103,78 @@ export function mapEditor(options: MapEditorOptions): {
}
const rows: HTMLElement[] = [];
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
const countInput = el('input', { class: 'input input--count', type: 'number' });
countInput.min = '1';
countInput.max = '255';
countInput.step = '1';
countInput.value = String(fill?.count ?? slot.count ?? 1);
countInput.title = t('slotCount');
const writeSlot = (): void => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
const parsed = Number.parseInt(countInput.value, 10);
const count = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (slot.count ?? 1);
countInput.value = String(count);
slots.push({ key: slot.key, thing: select.value, count });
}
room.slots = slots;
if (roomDef.homeroom) {
const seatsInput = el('input', { class: 'input input--count', type: 'number' });
seatsInput.min = '1';
seatsInput.max = '255';
seatsInput.step = '1';
seatsInput.value = String(room.seats ?? roomDef.defaultSeats ?? 16);
seatsInput.title = t('editorSeats');
seatsInput.addEventListener('change', () => {
const parsed = Number.parseInt(seatsInput.value, 10);
const seats = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (roomDef.defaultSeats || 16);
seatsInput.value = String(seats);
room.seats = seats;
room.slots = [];
emit();
};
select.addEventListener('change', writeSlot);
countInput.addEventListener('change', writeSlot);
});
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
countInput,
el('span', { class: 'field__label', text: t('editorSeats') }),
seatsInput,
),
);
} else {
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
const countInput = el('input', { class: 'input input--count', type: 'number' });
countInput.min = '1';
countInput.max = '255';
countInput.step = '1';
countInput.value = String(fill?.count ?? slot.count ?? 1);
countInput.title = t('slotCount');
const writeSlot = (): void => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
const parsed = Number.parseInt(countInput.value, 10);
const count = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (slot.count ?? 1);
countInput.value = String(count);
slots.push({ key: slot.key, thing: select.value, count });
}
room.slots = slots;
emit();
};
select.addEventListener('change', writeSlot);
countInput.addEventListener('change', writeSlot);
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
countInput,
),
);
}
}
return section(
@@ -251,7 +277,9 @@ export function mapEditor(options: MapEditorOptions): {
def: def.defName,
building: building.id,
floor: floor.id,
slots: defaultSlots(def),
...(def.homeroom
? { seats: def.defaultSeats || 16 }
: { slots: defaultSlots(def) }),
});
if (!hasLink(map, map.territory.id, id)) {
map.links.push({ a: map.territory.id, b: id });
+81 -18
View File
@@ -361,16 +361,12 @@ export class PeoplePanel {
el('h3', { class: 'people__card-name', text: card.fullName }),
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
);
appendStats(this.card, t('peopleBody'), card.body.map((row) => `${row.label}: ${row.value}`));
appendStats(this.card, t('peopleSkills'), card.skills.map((row) => `${row.label}: ${row.value}`));
appendStats(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
appendStats(
this.card,
t('peopleNeeds'),
card.needs.map((row) => `${row.label}: ${Math.round(row.value * 100)}%`),
);
appendPairs(this.card, t('peopleBody'), card.body);
appendPairs(this.card, t('peopleSkills'), card.skills);
appendTags(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
appendNeeds(this.card, t('peopleNeeds'), card.needs);
const family = el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: t('peopleFamily') }));
const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id));
appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id));
appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id));
@@ -445,17 +441,79 @@ function cardMeta(card: PersonCard): string {
return bits.join(' · ');
}
function appendStats(parent: HTMLElement, title: string, values: readonly string[]): void {
function section(title: string): HTMLElement {
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
}
/**
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
* the card past the fold while three quarters of its width sat empty.
*/
function appendPairs(
parent: HTMLElement,
title: string,
rows: readonly { readonly label: string; readonly value: string }[],
): void {
if (rows.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of rows) {
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: row.label }),
el('dd', { text: row.value }),
),
);
}
parent.append(section(title), grid);
}
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
if (values.length === 0) {
return;
}
const list = el('ul', { class: 'people__stats' });
const tags = el('div', { class: 'people__tags' });
for (const value of values) {
list.append(el('li', { text: value }));
tags.append(el('span', { class: 'people__tag', text: value }));
}
parent.append(el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: title }), list));
parent.append(section(title), tags);
}
function appendNeeds(
parent: HTMLElement,
title: string,
rows: readonly { readonly label: string; readonly value: number }[],
): void {
if (rows.length === 0) {
return;
}
const grid = el('div', { class: 'people__needs' });
for (const row of rows) {
const share = Math.max(0, Math.min(1, row.value));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
fill.classList.toggle('people__need-fill--low', share < 0.25);
grid.append(
el(
'div',
{ class: 'people__need' },
el('span', { class: 'people__need-label', text: row.label }),
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
el('span', { class: 'people__need-track' }, fill),
),
);
}
parent.append(section(title), grid);
}
function appendRelatives(
@@ -468,10 +526,9 @@ function appendRelatives(
return;
}
const list = el('ul', { class: 'people__stats' });
const list = el('span', { class: 'people__rel-list' });
for (const relative of relatives) {
const item = el('li');
item.append(
list.append(
el('button', {
class: 'people__link',
type: 'button',
@@ -479,8 +536,14 @@ function appendRelatives(
onClick: () => open(relative.id),
}),
);
list.append(item);
}
parent.append(el('h4', { class: 'panel__section-title', text: title }), list);
parent.append(
el(
'div',
{ class: 'people__rel' },
el('span', { class: 'people__rel-title', text: title }),
list,
),
);
}