Give each school its own people seed so composition no longer follows create order.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -261,10 +261,14 @@ Protocol ← Server → Simulation → People → Content
|
|||||||
|
|
||||||
### Детерминизм
|
### Детерминизм
|
||||||
|
|
||||||
Генерация детерминирована от **сида**, сид лежит в файле школы. Один сид + одна карта + один набор
|
Генерация детерминирована от **сида**. Сид — своё поле школы, не её идентификатор: при создании
|
||||||
|
его бросает сервер, либо его передают в `POST /api/schools`. Он лежит в файле людей и виден в
|
||||||
|
списке школ и на экране школы, чтобы им можно было поделиться. Один сид + одна карта + один набор
|
||||||
имён + один родной язык = одна и та же школа, всегда. Если родной язык при создании не указан,
|
имён + один родной язык = одна и та же школа, всегда. Если родной язык при создании не указан,
|
||||||
его выбирает тот же сид — повтор без поля даёт тот же язык.
|
его выбирает тот же сид — повтор без поля даёт тот же язык.
|
||||||
|
|
||||||
|
Существующие школы не перебрасываются: они продолжают жить с числом, уже записанным в файле людей.
|
||||||
|
|
||||||
Сид у каждой семьи свой, выведенный из школьного: добавление тринадцатой семьи не должно менять
|
Сид у каждой семьи свой, выведенный из школьного: добавление тринадцатой семьи не должно менять
|
||||||
первые двенадцать. Иначе тест на генератор ломается от любой правки порядка.
|
первые двенадцать. Иначе тест на генератор ломается от любой правки порядка.
|
||||||
|
|
||||||
@@ -389,7 +393,7 @@ Protocol ← Server → Simulation → People → Content
|
|||||||
| Падежи | Правило по умолчанию (код), явная таблица как исключение (данные) |
|
| Падежи | Правило по умолчанию (код), явная таблица как исключение (данные) |
|
||||||
| Имена и язык UI | Независимы |
|
| Имена и язык UI | Независимы |
|
||||||
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
|
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
|
||||||
| Детерминизм | Сид в сейве, свой сид на семью; родной язык — часть входа |
|
| Детерминизм | Свой сид школы (не id), в сейве и в API; свой сид на семью; родной язык — часть входа |
|
||||||
| Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран |
|
| Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран |
|
||||||
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
|
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
|
||||||
| Транспорт списка | HTTP по опубликованному снимку ростера |
|
| Транспорт списка | HTTP по опубликованному снимку ростера |
|
||||||
|
|||||||
@@ -15,20 +15,20 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] При создании школы сид берётся из генератора случайных чисел сервера, а не из id
|
- [x] При создании школы сид берётся из генератора случайных чисел сервера, а не из id
|
||||||
- [ ] `POST /api/schools` принимает необязательный `seed`; передали — берётся он, нет — бросается
|
- [x] `POST /api/schools` принимает необязательный `seed`; передали — берётся он, нет — бросается
|
||||||
- [ ] Сид виден: в ответе `GET /api/schools` и на экране школы, чтобы его можно было переслать
|
- [x] Сид виден: в ответе `GET /api/schools` и на экране школы, чтобы его можно было переслать
|
||||||
- [ ] Существующие школы не меняются: сид читается из файла людей, как и сейчас
|
- [x] Существующие школы не меняются: сид читается из файла людей, как и сейчас
|
||||||
- [ ] Тесты хоста, которым нужен предсказуемый состав, передают сид явно, а не полагаются на
|
- [x] Тесты хоста, которым нужен предсказуемый состав, передают сид явно, а не полагаются на
|
||||||
порядок создания
|
порядок создания
|
||||||
- [ ] `docs/protocol.md` и [`../design/people.md`](../design/people.md) правятся тем же коммитом
|
- [x] `docs/protocol.md` и [`../design/people.md`](../design/people.md) правятся тем же коммитом
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Две школы, созданные с одним сидом, населены одинаково; с разными — по-разному
|
- [x] Две школы, созданные с одним сидом, населены одинаково; с разными — по-разному
|
||||||
- [ ] Школа, созданная без сида, после перезапуска поднимает тот же состав
|
- [x] Школа, созданная без сида, после перезапуска поднимает тот же состав
|
||||||
- [ ] Старый сейв, где сид совпадал с id, грузится и состав не меняется
|
- [x] Старый сейв, где сид совпадал с id, грузится и состав не меняется
|
||||||
- [ ] Переданный сид виден в списке школ
|
- [x] Переданный сид виден в списке школ
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -32,6 +32,8 @@ the wire format is unambiguous, and the client formats it back in UTC.
|
|||||||
|
|
||||||
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
||||||
from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a catalog def.
|
from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a catalog def.
|
||||||
|
`seed` is the roster generator seed: it is not the school id. Share it to recreate the same
|
||||||
|
people; it does not change on a living school.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -40,7 +42,7 @@ from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a c
|
|||||||
"gameMinutesPerRealSecond": 5,
|
"gameMinutesPerRealSecond": 5,
|
||||||
"schoolWeekDays": 5,
|
"schoolWeekDays": 5,
|
||||||
"schools": [
|
"schools": [
|
||||||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 }
|
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "seed": 1847291 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -110,7 +112,8 @@ Body:
|
|||||||
"modIds": [],
|
"modIds": [],
|
||||||
"map": null,
|
"map": null,
|
||||||
"nameSetId": "Slavic",
|
"nameSetId": "Slavic",
|
||||||
"nativeLanguage": null
|
"nativeLanguage": null,
|
||||||
|
"seed": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,6 +123,8 @@ pack set's default layout. A supplied map is validated as a connected yard-and-r
|
|||||||
`Slavic`). Unknown ids return `400` `unknown-name-set`.
|
`Slavic`). Unknown ids return `400` `unknown-name-set`.
|
||||||
`nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick
|
`nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick
|
||||||
one from the school seed. An id that is not in the set returns `400` `unknown-native-language`.
|
one from the school seed. An id that is not in the set returns `400` `unknown-native-language`.
|
||||||
|
`seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and
|
||||||
|
the server rolls one. Existing saves keep the seed already stored in the people file.
|
||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ const ru = {
|
|||||||
editMap: 'Редактировать карту',
|
editMap: 'Редактировать карту',
|
||||||
mapDefaultHint: 'Будет использована карта по умолчанию.',
|
mapDefaultHint: 'Будет использована карта по умолчанию.',
|
||||||
mapEditedHint: 'Карта изменена.',
|
mapEditedHint: 'Карта изменена.',
|
||||||
|
schoolSeed: 'Сид {seed}',
|
||||||
|
schoolSeedLabel: 'Сид',
|
||||||
|
schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.',
|
||||||
|
schoolSeedInvalid: 'Сид должен быть целым числом.',
|
||||||
resetMap: 'Сбросить к умолчанию',
|
resetMap: 'Сбросить к умолчанию',
|
||||||
done: 'Готово',
|
done: 'Готово',
|
||||||
editorStructure: 'Структура',
|
editorStructure: 'Структура',
|
||||||
@@ -266,6 +270,10 @@ const en: Messages = {
|
|||||||
editMap: 'Edit map',
|
editMap: 'Edit map',
|
||||||
mapDefaultHint: 'The default map will be used.',
|
mapDefaultHint: 'The default map will be used.',
|
||||||
mapEditedHint: 'The map has been edited.',
|
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',
|
resetMap: 'Reset to default',
|
||||||
done: 'Done',
|
done: 'Done',
|
||||||
editorStructure: 'Structure',
|
editorStructure: 'Structure',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface School {
|
|||||||
readonly gameTime: string;
|
readonly gameTime: string;
|
||||||
readonly running: boolean;
|
readonly running: boolean;
|
||||||
readonly speedIndex: number;
|
readonly speedIndex: number;
|
||||||
|
/** Roster generator seed. Independent of `id`; share it to recreate the same people. */
|
||||||
|
readonly seed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchoolsResponse {
|
export interface SchoolsResponse {
|
||||||
@@ -51,6 +53,7 @@ export interface CreateSchoolOptions {
|
|||||||
readonly map?: MapLayout;
|
readonly map?: MapLayout;
|
||||||
readonly nameSetId?: string;
|
readonly nameSetId?: string;
|
||||||
readonly nativeLanguage?: string;
|
readonly nativeLanguage?: string;
|
||||||
|
readonly seed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSchool(
|
export async function createSchool(
|
||||||
@@ -68,6 +71,7 @@ export async function createSchool(
|
|||||||
map: extras.map ?? null,
|
map: extras.map ?? null,
|
||||||
nameSetId: extras.nameSetId ?? null,
|
nameSetId: extras.nameSetId ?? null,
|
||||||
nativeLanguage: extras.nativeLanguage ?? null,
|
nativeLanguage: extras.nativeLanguage ?? null,
|
||||||
|
seed: extras.seed ?? null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ body {
|
|||||||
font-weight: 600;
|
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 {
|
.screen__actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
nativeLanguageId = nativeSelect.value === '' ? null : nativeSelect.value;
|
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 mapLabel = el('span', { class: 'field__label' });
|
||||||
const editMapButton = el('button', { class: 'button', type: 'button' });
|
const editMapButton = el('button', { class: 'button', type: 'button' });
|
||||||
const mapHint = el('p', { class: 'hint' });
|
const mapHint = el('p', { class: 'hint' });
|
||||||
@@ -107,6 +113,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
nameSetField,
|
nameSetField,
|
||||||
nativeField,
|
nativeField,
|
||||||
mapField,
|
mapField,
|
||||||
|
seedField,
|
||||||
error,
|
error,
|
||||||
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
||||||
);
|
);
|
||||||
@@ -120,6 +127,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
randomButton.toggleAttribute('disabled', value);
|
randomButton.toggleAttribute('disabled', value);
|
||||||
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
|
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
|
||||||
editMapButton.toggleAttribute('disabled', waiting);
|
editMapButton.toggleAttribute('disabled', waiting);
|
||||||
|
seedInput.toggleAttribute('disabled', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showError = (message: string): void => {
|
const showError = (message: string): void => {
|
||||||
@@ -238,6 +246,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
nativeLabel.textContent = t('nativeLanguageTitle');
|
nativeLabel.textContent = t('nativeLanguageTitle');
|
||||||
nativeRandomButton.textContent = t('randomName');
|
nativeRandomButton.textContent = t('randomName');
|
||||||
nativeRandomButton.title = t('randomNativeTitle');
|
nativeRandomButton.title = t('randomNativeTitle');
|
||||||
|
seedLabel.textContent = t('schoolSeedLabel');
|
||||||
|
seedInput.placeholder = t('schoolSeedLabel');
|
||||||
|
seedHint.textContent = t('schoolSeedHint');
|
||||||
nameLabel.textContent = t('schoolName');
|
nameLabel.textContent = t('schoolName');
|
||||||
startLabel.textContent = t('gameStart');
|
startLabel.textContent = t('gameStart');
|
||||||
mapLabel.textContent = t('mapEditorTitle');
|
mapLabel.textContent = t('mapEditorTitle');
|
||||||
@@ -310,6 +321,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seed = parseSeed(seedInput.value);
|
||||||
|
if (seedInput.value.trim() !== '' && seed === null) {
|
||||||
|
showError(t('schoolSeedInvalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
options
|
options
|
||||||
.create(nameInput.value.trim(), startDate, {
|
.create(nameInput.value.trim(), startDate, {
|
||||||
@@ -317,6 +334,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
map: currentMap,
|
map: currentMap,
|
||||||
nameSetId: nameSetId ?? undefined,
|
nameSetId: nameSetId ?? undefined,
|
||||||
nativeLanguage: nativeLanguageId ?? undefined,
|
nativeLanguage: nativeLanguageId ?? undefined,
|
||||||
|
seed: seed ?? undefined,
|
||||||
})
|
})
|
||||||
.then((school) => modal.close(school))
|
.then((school) => modal.close(school))
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
@@ -343,6 +361,24 @@ function mapsEqual(left: MapLayout, right: MapLayout): boolean {
|
|||||||
return JSON.stringify(left) === JSON.stringify(right);
|
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 {
|
function describe(reason: unknown): string {
|
||||||
if (!(reason instanceof ApiError)) {
|
if (!(reason instanceof ApiError)) {
|
||||||
return t('serverUnavailable');
|
return t('serverUnavailable');
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class GameScreen {
|
|||||||
private readonly root = el('section', { class: 'screen game' });
|
private readonly root = el('section', { class: 'screen game' });
|
||||||
private readonly backButton = el('button', { class: 'button', type: 'button' });
|
private readonly backButton = el('button', { class: 'button', type: 'button' });
|
||||||
private readonly schoolName = el('h1', { class: 'screen__title' });
|
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 time = el('p', { class: 'clock__time', text: '--:--' });
|
||||||
private readonly date = el('p', { class: 'clock__date' });
|
private readonly date = el('p', { class: 'clock__date' });
|
||||||
private readonly weekday = el('p', { class: 'clock__weekday' });
|
private readonly weekday = el('p', { class: 'clock__weekday' });
|
||||||
@@ -79,6 +80,7 @@ export class GameScreen {
|
|||||||
private directoryToken = 0;
|
private directoryToken = 0;
|
||||||
private selectedId: string | null = null;
|
private selectedId: string | null = null;
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
|
private peopleSeed: number | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
private lastGameTime: Date | null = null;
|
private lastGameTime: Date | null = null;
|
||||||
private lastSpeedIndex = 0;
|
private lastSpeedIndex = 0;
|
||||||
@@ -102,7 +104,7 @@ export class GameScreen {
|
|||||||
this.skipButton.hidden = true;
|
this.skipButton.hidden = true;
|
||||||
|
|
||||||
this.root.append(
|
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(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'clockbar' },
|
{ class: 'clockbar' },
|
||||||
@@ -188,6 +190,7 @@ export class GameScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.paintSkip();
|
this.paintSkip();
|
||||||
|
this.paintSeed();
|
||||||
this.people.setLocate((id) => this.placeOf(id));
|
this.people.setLocate((id) => this.placeOf(id));
|
||||||
this.management.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. */
|
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
||||||
show(school: School): void {
|
show(school: School): void {
|
||||||
this.schoolId = school.id;
|
this.schoolId = school.id;
|
||||||
|
this.peopleSeed = school.seed;
|
||||||
this.schoolName.textContent = school.name;
|
this.schoolName.textContent = school.name;
|
||||||
|
this.paintSeed();
|
||||||
this.nodes = [];
|
this.nodes = [];
|
||||||
this.presence = null;
|
this.presence = null;
|
||||||
this.directory = new Map();
|
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 {
|
private paintTreeLabels(): void {
|
||||||
for (const [id, button] of this.treeButtons) {
|
for (const [id, button] of this.treeButtons) {
|
||||||
const node = this.nodes.find((candidate) => candidate.id === id);
|
const node = this.nodes.find((candidate) => candidate.id === id);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ internal static class SchoolEndpoints
|
|||||||
request.Map,
|
request.Map,
|
||||||
request.NameSetId,
|
request.NameSetId,
|
||||||
request.NativeLanguage,
|
request.NativeLanguage,
|
||||||
|
request.Seed,
|
||||||
NewCompletion<SchoolCreationOutcome>());
|
NewCompletion<SchoolCreationOutcome>());
|
||||||
commands.Enqueue(command);
|
commands.Enqueue(command);
|
||||||
|
|
||||||
@@ -490,12 +491,13 @@ internal sealed record CreateSchoolRequest(
|
|||||||
IReadOnlyList<string>? ModIds,
|
IReadOnlyList<string>? ModIds,
|
||||||
MapLayout? Map,
|
MapLayout? Map,
|
||||||
string? NameSetId,
|
string? NameSetId,
|
||||||
string? NativeLanguage);
|
string? NativeLanguage,
|
||||||
|
int? Seed);
|
||||||
|
|
||||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
|
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed)
|
||||||
{
|
{
|
||||||
public static SchoolResponse From(SchoolState school) =>
|
public static SchoolResponse From(SchoolState school) =>
|
||||||
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
|
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.Seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Everything the main menu needs in one request.</summary>
|
/// <summary>Everything the main menu needs in one request.</summary>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ internal abstract record GameCommand
|
|||||||
MapLayout? Map,
|
MapLayout? Map,
|
||||||
string? NameSetId,
|
string? NameSetId,
|
||||||
string? NativeLanguage,
|
string? NativeLanguage,
|
||||||
|
int? Seed,
|
||||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||||
|
|
||||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||||
|
|||||||
@@ -342,9 +342,10 @@ internal sealed class GameLoopService(
|
|||||||
|
|
||||||
var id = _nextId++;
|
var id = _nextId++;
|
||||||
store.WriteNextId(_nextId);
|
store.WriteNextId(_nextId);
|
||||||
var nativeLanguage = NativeLanguages.Pick(names, id, command.NativeLanguage, rollIfOmitted: true);
|
var seed = command.Seed ?? Random.Shared.Next();
|
||||||
|
var nativeLanguage = NativeLanguages.Pick(names, seed, command.NativeLanguage, rollIfOmitted: true);
|
||||||
|
|
||||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage);
|
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage, seed);
|
||||||
Track(worker);
|
Track(worker);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -366,7 +367,7 @@ internal sealed class GameLoopService(
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("School {SchoolId} \"{Name}\" created.", id, normalized);
|
logger.LogInformation("School {SchoolId} \"{Name}\" created with seed {Seed}.", id, normalized, seed);
|
||||||
command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None));
|
command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -513,6 +514,7 @@ internal sealed class GameLoopService(
|
|||||||
save.Map,
|
save.Map,
|
||||||
save.NameSetId,
|
save.NameSetId,
|
||||||
save.NativeLanguage,
|
save.NativeLanguage,
|
||||||
|
createSeed: null,
|
||||||
save.Presence);
|
save.Presence);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -562,6 +564,7 @@ internal sealed class GameLoopService(
|
|||||||
MapLayout? map,
|
MapLayout? map,
|
||||||
string? nameSetId,
|
string? nameSetId,
|
||||||
string? nativeLanguage,
|
string? nativeLanguage,
|
||||||
|
int? createSeed = null,
|
||||||
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
||||||
new(
|
new(
|
||||||
id,
|
id,
|
||||||
@@ -574,6 +577,7 @@ internal sealed class GameLoopService(
|
|||||||
map,
|
map,
|
||||||
nameSetId,
|
nameSetId,
|
||||||
nativeLanguage,
|
nativeLanguage,
|
||||||
|
createSeed,
|
||||||
presence,
|
presence,
|
||||||
_options,
|
_options,
|
||||||
clients,
|
clients,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace HSchool.Server.Game;
|
|||||||
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
|
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
|
||||||
/// never leaves its worker thread.
|
/// never leaves its worker thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
|
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed);
|
||||||
|
|
||||||
/// <summary>Everything the main menu needs in one read.</summary>
|
/// <summary>Everything the main menu needs in one read.</summary>
|
||||||
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
|
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly MapLayout? _savedMap;
|
private readonly MapLayout? _savedMap;
|
||||||
private readonly string? _nameSetId;
|
private readonly string? _nameSetId;
|
||||||
private string? _nativeLanguage;
|
private string? _nativeLanguage;
|
||||||
|
private readonly int? _createSeed;
|
||||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||||
private readonly Action<int> _onFailed;
|
private readonly Action<int> _onFailed;
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ internal sealed class SchoolWorker
|
|||||||
MapLayout? savedMap,
|
MapLayout? savedMap,
|
||||||
string? nameSetId,
|
string? nameSetId,
|
||||||
string? nativeLanguage,
|
string? nativeLanguage,
|
||||||
|
int? createSeed,
|
||||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
@@ -86,6 +88,7 @@ internal sealed class SchoolWorker
|
|||||||
_savedMap = savedMap;
|
_savedMap = savedMap;
|
||||||
_nameSetId = nameSetId;
|
_nameSetId = nameSetId;
|
||||||
_nativeLanguage = nativeLanguage;
|
_nativeLanguage = nativeLanguage;
|
||||||
|
_createSeed = createSeed;
|
||||||
_savedPresence = savedPresence;
|
_savedPresence = savedPresence;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clients = clients;
|
_clients = clients;
|
||||||
@@ -94,7 +97,7 @@ internal sealed class SchoolWorker
|
|||||||
_mods = mods;
|
_mods = mods;
|
||||||
_onFailed = onFailed;
|
_onFailed = onFailed;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, createSeed ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Id => _id;
|
public int Id => _id;
|
||||||
@@ -577,7 +580,8 @@ internal sealed class SchoolWorker
|
|||||||
school.Name,
|
school.Name,
|
||||||
school.Clock.Time,
|
school.Clock.Time,
|
||||||
school.Clock.IsRunning,
|
school.Clock.IsRunning,
|
||||||
(byte)school.Clock.SpeedIndex));
|
(byte)school.Clock.SpeedIndex,
|
||||||
|
school.PeopleSeed));
|
||||||
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
||||||
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
||||||
Volatile.Write(ref _timetableSnapshot, school.Timetable);
|
Volatile.Write(ref _timetableSnapshot, school.Timetable);
|
||||||
@@ -856,7 +860,12 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
if (_isNew)
|
if (_isNew)
|
||||||
{
|
{
|
||||||
seed = school.Id;
|
if (_createSeed is not int createSeed)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"School {_id} was created without a people seed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
seed = createSeed;
|
||||||
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
||||||
_nativeLanguage = native;
|
_nativeLanguage = native;
|
||||||
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
|
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class GoldenSaveTests(AppHostFixture fixture)
|
|||||||
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||||||
var school = Assert.Single(state.Schools);
|
var school = Assert.Single(state.Schools);
|
||||||
Assert.Equal(1, school.Id);
|
Assert.Equal(1, school.Id);
|
||||||
|
Assert.Equal(1, school.Seed);
|
||||||
Assert.Equal("Золотая", school.Name);
|
Assert.Equal("Золотая", school.Name);
|
||||||
Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime);
|
Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime);
|
||||||
Assert.False(school.Running);
|
Assert.False(school.Running);
|
||||||
@@ -34,6 +35,8 @@ public class GoldenSaveTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||||||
var school = Assert.Single(state.Schools);
|
var school = Assert.Single(state.Schools);
|
||||||
|
Assert.Equal(1, school.Id);
|
||||||
|
Assert.Equal(1, school.Seed);
|
||||||
Assert.Equal("Золотая", school.Name);
|
Assert.Equal("Золотая", school.Name);
|
||||||
|
|
||||||
var people = await PeopleAsync(client, school.Id);
|
var people = await PeopleAsync(client, school.Id);
|
||||||
|
|||||||
@@ -139,14 +139,14 @@ public class PeopleApiTests(AppHostFixture fixture)
|
|||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start);
|
var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start, seed: 1);
|
||||||
|
|
||||||
var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=10");
|
var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=10");
|
||||||
|
|
||||||
// Every fifth-to-tenth family is single-parent on purpose (design/people.md), and the
|
// Every fifth-to-tenth family is single-parent on purpose (design/people.md). The roster
|
||||||
// roster seed is the school id — which depends on how many schools ran before this test.
|
// seed is no longer the school id, but a given page still mixes complete and single-parent
|
||||||
// Demanding two parents from whoever happens to sort first therefore fails on some runs;
|
// houses, so demanding two parents from whoever sorts first fails on some seeds. Look for
|
||||||
// look for a pupil who has a mother instead of assuming the first one does.
|
// a pupil who has a mother instead of assuming the first one does.
|
||||||
PersonCardResponse? card = null;
|
PersonCardResponse? card = null;
|
||||||
foreach (var candidate in page.People)
|
foreach (var candidate in page.People)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -371,9 +371,13 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static async Task<SchoolResponse> CreateAsync(HttpClient client, string name, DateTime startDate)
|
internal static async Task<SchoolResponse> CreateAsync(
|
||||||
|
HttpClient client,
|
||||||
|
string name,
|
||||||
|
DateTime startDate,
|
||||||
|
int? seed = null)
|
||||||
{
|
{
|
||||||
using var response = await PostAsync(client, name, startDate);
|
using var response = await PostAsync(client, name, startDate, seed);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
|
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
|
||||||
@@ -406,10 +410,14 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
links = new[] { new { a = "yard", b = "office" } },
|
links = new[] { new { a = "yard", b = "office" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) =>
|
private static Task<HttpResponseMessage> PostAsync(
|
||||||
|
HttpClient client,
|
||||||
|
string name,
|
||||||
|
DateTime startDate,
|
||||||
|
int? seed = null) =>
|
||||||
client.PostAsJsonAsync(
|
client.PostAsJsonAsync(
|
||||||
"/api/schools",
|
"/api/schools",
|
||||||
new { name, startDate },
|
seed is null ? (object)new { name, startDate } : new { name, startDate, seed },
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
|
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
|
||||||
@@ -418,7 +426,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
return problem?.Code;
|
return problem?.Code;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
|
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed);
|
||||||
|
|
||||||
internal sealed record SchoolsResponse(
|
internal sealed record SchoolsResponse(
|
||||||
int MaxSchools,
|
int MaxSchools,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The people seed is a school field of its own, not the school id. These tests share the
|
||||||
|
/// AppHost, so each one starts by clearing the list.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(AppHostCollection.Name)]
|
||||||
|
public class SchoolSeedTests(AppHostFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameSeed_PopulatesTheSamePeople_DifferentSeedsDoNot()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
|
||||||
|
var first = await SchoolApiTests.CreateAsync(client, "Сид одинаковый А", Start, seed: 42);
|
||||||
|
var second = await SchoolApiTests.CreateAsync(client, "Сид одинаковый Б", Start, seed: 42);
|
||||||
|
var other = await SchoolApiTests.CreateAsync(client, "Сид другой", Start, seed: 7);
|
||||||
|
|
||||||
|
var firstYearFive = await YearFiveAsync(client, first.Id);
|
||||||
|
var secondYearFive = await YearFiveAsync(client, second.Id);
|
||||||
|
var otherYearFive = await YearFiveAsync(client, other.Id);
|
||||||
|
|
||||||
|
Assert.Equal(firstYearFive, secondYearFive);
|
||||||
|
Assert.NotEqual(firstYearFive, otherYearFive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnseededSchool_KeepsItsPeopleAfterReload()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
|
||||||
|
var created = await SchoolApiTests.CreateAsync(client, "Сид брошен", Start);
|
||||||
|
|
||||||
|
var before = await YearFiveAsync(client, created.Id);
|
||||||
|
|
||||||
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var restored = (await SchoolApiTests.GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
|
||||||
|
Assert.Equal(created.Seed, restored.Seed);
|
||||||
|
Assert.Equal(before, await YearFiveAsync(client, created.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PassedSeed_IsVisibleInTheSchoolList()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
|
||||||
|
var created = await SchoolApiTests.CreateAsync(client, "Сид в списке", Start, seed: 20260819);
|
||||||
|
|
||||||
|
Assert.Equal(20260819, created.Seed);
|
||||||
|
|
||||||
|
var listed = (await SchoolApiTests.GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
|
||||||
|
Assert.Equal(20260819, listed.Seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TwoUnseededSchools_ArePopulatedDifferently()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
|
||||||
|
var first = await SchoolApiTests.CreateAsync(client, "Без сида А", Start);
|
||||||
|
var second = await SchoolApiTests.CreateAsync(client, "Без сида Б", Start);
|
||||||
|
|
||||||
|
Assert.NotEqual(first.Seed, second.Seed);
|
||||||
|
Assert.NotEqual(await YearFiveAsync(client, first.Id), await YearFiveAsync(client, second.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(string Id, string FullName)[]> YearFiveAsync(HttpClient client, int schoolId)
|
||||||
|
{
|
||||||
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{schoolId}/people?role=student&year=5&sort=surname&pageSize=100",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(page);
|
||||||
|
Assert.NotEmpty(page.People);
|
||||||
|
return [.. page.People.Select(person => (person.Id, person.FullName))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
||||||
|
|
||||||
|
private sealed record PersonRow(string Id, string FullName);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user