Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+316
View File
@@ -0,0 +1,316 @@
import type { CatalogResponse, MapLayout, RoomInfo } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
interface MapEditorOptions {
catalog: CatalogResponse;
map: MapLayout;
onChange: (map: MapLayout) => void;
}
/**
* Create-only map editor: tree, slot fills, passage links, add/remove room, reset to the pack default.
* The server still validates the graph on POST.
*/
export function mapEditor(options: MapEditorOptions): {
readonly element: HTMLElement;
setCatalog: (catalog: CatalogResponse, map: MapLayout) => void;
localize: () => void;
} {
let catalog = options.catalog;
let map = cloneMap(options.map);
let selectedId: string | null = map.territory.id;
const tree = el('ul', { class: 'tree map-editor__tree' });
const details = el('div', { class: 'map-editor__details' });
const root = el(
'div',
{ class: 'map-editor' },
el('div', { class: 'map-editor__pane' }, tree),
details,
);
const emit = (): void => {
options.onChange(cloneMap(map));
render();
};
const render = (): void => {
paintTree();
paintDetails();
};
const paintTree = (): void => {
clear(tree);
appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0);
for (const building of map.buildings) {
appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1);
for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) {
const floorName = floor.label !== undefined && floor.label.length > 0
? floor.label
: (defLabel(catalog.floors, floor.def) ?? floor.id);
appendNode(tree, floor.id, floorName, 2);
for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) {
appendNode(tree, room.id, defLabel(catalog.rooms, room.def) ?? room.id, 3);
}
}
}
};
const appendNode = (list: HTMLUListElement, id: string, name: string, depth: number): void => {
const item = el('li', { class: 'tree__node' });
const button = el('button', {
class: selectedId === id ? 'tree__button tree__button--active' : 'tree__button',
type: 'button',
text: name,
onClick: () => {
selectedId = id;
render();
},
});
button.style.paddingLeft = `${8 + depth * 14}px`;
item.append(button);
list.append(item);
};
const paintDetails = (): void => {
clear(details);
const room = map.rooms.find((candidate) => candidate.id === selectedId);
const roomDef = room === undefined ? undefined : catalog.rooms.find((def) => def.defName === room.def);
if (room !== undefined && roomDef !== undefined) {
details.append(el('p', { class: 'field__label', text: t('editorSlots') }));
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 ?? '';
select.addEventListener('change', () => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
slots.push({ key: slot.key, thing: select.value });
}
room.slots = slots;
emit();
});
details.append(
el('label', { class: 'field' }, el('span', { class: 'field__label', text: slot.key }), select),
);
}
const remove = el('button', {
class: 'button',
type: 'button',
text: t('removeRoom'),
onClick: () => {
map.rooms = map.rooms.filter((candidate) => candidate.id !== room.id);
map.links = map.links.filter((link) => link.a !== room.id && link.b !== room.id);
selectedId = map.territory.id;
emit();
},
});
details.append(remove);
}
details.append(el('p', { class: 'field__label', text: t('editorLinks') }));
const linksList = el('ul', { class: 'map-editor__links' });
for (const [index, link] of map.links.entries()) {
const row = el('li', { class: 'map-editor__link' });
row.append(
el('span', { text: `${labelOf(catalog, map, link.a)}${labelOf(catalog, map, link.b)}` }),
el('button', {
class: 'button button--small',
type: 'button',
text: t('removeLink'),
onClick: () => {
map.links = map.links.filter((_, current) => current !== index);
emit();
},
}),
);
linksList.append(row);
}
details.append(linksList);
const walkable = walkableIds(map);
const from = selectOf(walkable, catalog, map);
const to = selectOf(walkable, catalog, map);
details.append(
el(
'div',
{ class: 'field__row' },
from,
to,
el('button', {
class: 'button',
type: 'button',
text: t('addLink'),
onClick: () => {
const a = from.value;
const b = to.value;
if (a === b || hasLink(map, a, b)) {
return;
}
map.links.push({ a, b });
emit();
},
}),
),
);
const roomSelect = el('select', { class: 'input' });
for (const def of catalog.rooms) {
const option = el('option', { text: def.label });
option.value = def.defName;
roomSelect.append(option);
}
details.append(
el(
'div',
{ class: 'field__row' },
roomSelect,
el('button', {
class: 'button',
type: 'button',
text: t('addRoom'),
disabled: catalog.rooms.length === 0 || map.buildings.length === 0 || map.floors.length === 0,
onClick: () => {
const def = catalog.rooms.find((candidate) => candidate.defName === roomSelect.value);
const building = map.buildings[0];
const floor = map.floors[0];
if (def === undefined || building === undefined || floor === undefined) {
return;
}
const id = nextId(slug(def.defName), usedIds(map));
map.rooms.push({
id,
def: def.defName,
building: building.id,
floor: floor.id,
slots: defaultSlots(def),
});
if (!hasLink(map, map.territory.id, id)) {
map.links.push({ a: map.territory.id, b: id });
}
selectedId = id;
emit();
},
}),
),
);
};
render();
return {
element: root,
setCatalog: (nextCatalog, nextMap) => {
catalog = nextCatalog;
map = cloneMap(nextMap);
selectedId = map.territory.id;
render();
},
localize: () => render(),
};
}
function cloneMap(map: MapLayout): MapLayout {
return structuredClone(map);
}
function usedIds(map: MapLayout): Set<string> {
return new Set([
map.territory.id,
...map.buildings.map((node) => node.id),
...map.floors.map((node) => node.id),
...map.rooms.map((node) => node.id),
]);
}
function walkableIds(map: MapLayout): string[] {
return [map.territory.id, ...map.rooms.map((room) => room.id)];
}
function hasLink(map: MapLayout, a: string, b: string): boolean {
return map.links.some(
(link) => (link.a === a && link.b === b) || (link.a === b && link.b === a),
);
}
function defaultSlots(def: RoomInfo): { key: string; thing: string }[] {
return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing }));
}
function nextId(prefix: string, used: Set<string>): string {
let n = 1;
let candidate = `${prefix}-${n}`;
while (used.has(candidate)) {
n += 1;
candidate = `${prefix}-${n}`;
}
return candidate;
}
function slug(defName: string): string {
return defName
.replaceAll(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
}
function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined {
return defs.find((def) => def.defName === defName)?.label;
}
function labelOf(catalog: CatalogResponse, map: MapLayout, id: string): string {
if (map.territory.id === id) {
return defLabel(catalog.territories, map.territory.def) ?? id;
}
const building = map.buildings.find((node) => node.id === id);
if (building !== undefined) {
return defLabel(catalog.buildings, building.def) ?? id;
}
const floor = map.floors.find((node) => node.id === id);
if (floor !== undefined) {
return floor.label !== undefined && floor.label.length > 0
? floor.label
: (defLabel(catalog.floors, floor.def) ?? id);
}
const room = map.rooms.find((node) => node.id === id);
if (room !== undefined) {
return defLabel(catalog.rooms, room.def) ?? id;
}
return id;
}
function selectOf(ids: readonly string[], catalog: CatalogResponse, map: MapLayout): HTMLSelectElement {
const select = el('select', { class: 'input' });
for (const id of ids) {
const option = el('option', { text: labelOf(catalog, map, id) });
option.value = id;
select.append(option);
}
return select;
}