add shel
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user