Merge phase 26 school seed.
ci / server (push) Failing after 3m34s
ci / client (push) Failing after 12s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 00:43:33 +03:00
co-authored by Cursor
18 changed files with 237 additions and 35 deletions
+8
View File
@@ -57,6 +57,10 @@ const ru = {
editMap: 'Редактировать карту',
mapDefaultHint: 'Будет использована карта по умолчанию.',
mapEditedHint: 'Карта изменена.',
schoolSeed: 'Сид {seed}',
schoolSeedLabel: 'Сид',
schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.',
schoolSeedInvalid: 'Сид должен быть целым числом.',
resetMap: 'Сбросить к умолчанию',
done: 'Готово',
editorStructure: 'Структура',
@@ -270,6 +274,10 @@ const en: Messages = {
editMap: 'Edit map',
mapDefaultHint: 'The default map will be used.',
mapEditedHint: 'The map has been edited.',
schoolSeed: 'Seed {seed}',
schoolSeedLabel: 'Seed',
schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.',
schoolSeedInvalid: 'The seed must be a whole number.',
resetMap: 'Reset to default',
done: 'Done',
editorStructure: 'Structure',
+5
View File
@@ -10,7 +10,10 @@ export interface School {
readonly gameTime: string;
readonly running: boolean;
readonly speedIndex: number;
readonly speedIndex: number;
readonly modIds?: readonly string[];
/** Roster generator seed. Independent of `id`; share it to recreate the same people. */
readonly seed: number;
}
export interface SchoolsResponse {
@@ -53,6 +56,7 @@ export interface CreateSchoolOptions {
readonly map?: MapLayout;
readonly nameSetId?: string;
readonly nativeLanguage?: string;
readonly seed?: number;
}
export async function createSchool(
@@ -70,6 +74,7 @@ export async function createSchool(
map: extras.map ?? null,
nameSetId: extras.nameSetId ?? null,
nativeLanguage: extras.nativeLanguage ?? null,
seed: extras.seed ?? null,
}),
});
}
+15
View File
@@ -125,6 +125,21 @@ body {
font-weight: 600;
}
.screen__heading {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.screen__seed {
margin: 0;
color: var(--text-muted);
font-size: 13px;
font-weight: 400;
user-select: all;
}
.screen__actions {
margin-left: auto;
}
@@ -64,6 +64,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nativeLanguageId = nativeSelect.value === '' ? null : nativeSelect.value;
});
const seedLabel = el('span', { class: 'field__label' });
const seedInput = el('input', { class: 'input', type: 'text' });
seedInput.inputMode = 'numeric';
const seedHint = el('p', { class: 'hint' });
const seedField = el('label', { class: 'field' }, seedLabel, seedInput, seedHint);
const mapLabel = el('span', { class: 'field__label' });
const editMapButton = el('button', { class: 'button', type: 'button' });
const mapHint = el('p', { class: 'hint' });
@@ -107,6 +113,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nameSetField,
nativeField,
mapField,
seedField,
error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
);
@@ -120,6 +127,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
randomButton.toggleAttribute('disabled', value);
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
editMapButton.toggleAttribute('disabled', waiting);
seedInput.toggleAttribute('disabled', value);
};
const showError = (message: string): void => {
@@ -238,6 +246,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
nativeLabel.textContent = t('nativeLanguageTitle');
nativeRandomButton.textContent = t('randomName');
nativeRandomButton.title = t('randomNativeTitle');
seedLabel.textContent = t('schoolSeedLabel');
seedInput.placeholder = t('schoolSeedLabel');
seedHint.textContent = t('schoolSeedHint');
nameLabel.textContent = t('schoolName');
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle');
@@ -310,6 +321,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return;
}
const seed = parseSeed(seedInput.value);
if (seedInput.value.trim() !== '' && seed === null) {
showError(t('schoolSeedInvalid'));
return;
}
setBusy(true);
options
.create(nameInput.value.trim(), startDate, {
@@ -317,6 +334,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
map: currentMap,
nameSetId: nameSetId ?? undefined,
nativeLanguage: nativeLanguageId ?? undefined,
seed: seed ?? undefined,
})
.then((school) => modal.close(school))
.catch((reason: unknown) => {
@@ -343,6 +361,24 @@ function mapsEqual(left: MapLayout, right: MapLayout): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function parseSeed(value: string): number | null {
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
if (!/^-?\d+$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed) || parsed < -2147483648 || parsed > 2147483647) {
return null;
}
return parsed;
}
function describe(reason: unknown): string {
if (!(reason instanceof ApiError)) {
return t('serverUnavailable');
+10 -1
View File
@@ -34,6 +34,7 @@ export class GameScreen {
private readonly root = el('section', { class: 'screen game' });
private readonly backButton = el('button', { class: 'button', type: 'button' });
private readonly schoolName = el('h1', { class: 'screen__title' });
private readonly schoolSeed = el('p', { class: 'screen__seed' });
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
private readonly date = el('p', { class: 'clock__date' });
private readonly weekday = el('p', { class: 'clock__weekday' });
@@ -79,6 +80,7 @@ export class GameScreen {
private directoryToken = 0;
private selectedId: string | null = null;
private schoolId: number | null = null;
private peopleSeed: number | null = null;
private running = false;
private lastGameTime: Date | null = null;
private lastSpeedIndex = 0;
@@ -102,7 +104,7 @@ export class GameScreen {
this.skipButton.hidden = true;
this.root.append(
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
el('header', { class: 'screen__header' }, this.backButton, el('div', { class: 'screen__heading' }, this.schoolName, this.schoolSeed)),
el(
'div',
{ class: 'clockbar' },
@@ -188,6 +190,7 @@ export class GameScreen {
}
this.paintSkip();
this.paintSeed();
this.people.setLocate((id) => this.placeOf(id));
this.management.setLocate((id) => this.placeOf(id));
}
@@ -195,7 +198,9 @@ export class GameScreen {
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
show(school: School): void {
this.schoolId = school.id;
this.peopleSeed = school.seed;
this.schoolName.textContent = school.name;
this.paintSeed();
this.nodes = [];
this.presence = null;
this.directory = new Map();
@@ -381,6 +386,10 @@ export class GameScreen {
: '';
}
private paintSeed(): void {
this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed });
}
private paintTreeLabels(): void {
for (const [id, button] of this.treeButtons) {
const node = this.nodes.find((candidate) => candidate.id === id);