Merge branch 'phase/39-school-owners'
This commit is contained in:
@@ -11,31 +11,31 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `SimulationOptions.MaxSchools` — слоты **игрока**, умолчание 2.
|
- [x] `SimulationOptions.MaxSchools` — слоты **игрока**, умолчание 2.
|
||||||
`MaxSchoolsTotal` — воркеры процесса, умолчание 16. Подъём с диска режет по Total, не по
|
`MaxSchoolsTotal` — воркеры процесса, умолчание 16. Подъём с диска режет по Total, не по
|
||||||
слотам игрока
|
слотам игрока
|
||||||
- [ ] Сейв несёт `owner`. Нет поля — бесхозная. Формат не бампить
|
- [x] Сейв несёт `owner`. Нет поля — бесхозная. Формат не бампить
|
||||||
- [ ] Create пишет текущего пользователя; отказ: свои слоты кончились (`limit-reached`) или
|
- [x] Create пишет текущего пользователя; отказ: свои слоты кончились (`limit-reached`) или
|
||||||
сервер полон (`server-full`)
|
сервер полон (`server-full`)
|
||||||
- [ ] `GET /api/schools`: `maxSchools` — слоты игрока, `maxSchoolsTotal`, `schools` — свои,
|
- [x] `GET /api/schools`: `maxSchools` — слоты игрока, `maxSchoolsTotal`, `schools` — свои,
|
||||||
`others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine`
|
`others` — чужие и бесхозные (`owner` имя или `null`). Карточка школы несёт `mine`
|
||||||
- [ ] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый)
|
- [x] Welcome.`MaxSchools` — слоты игрока (байт тот же, смысл новый)
|
||||||
- [ ] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный.
|
- [x] Мутации чужой — HTTP `403` `not-owner`. Бесхозную может удалить любой залогиненный.
|
||||||
`OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят.
|
`OpenSchool` — всем с сессией. Пауза / скорость / пропуск от гостя до работника не доходят.
|
||||||
**Исключение:** `POST .../portrait` — гость генерирует теми же пресетами школы; это не управление
|
**Исключение:** `POST .../portrait` — гость генерирует теми же пресетами школы; это не управление
|
||||||
- [ ] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет
|
- [x] Меню: блок «Мои» и блок «Чужие». У бесхозной в чужих — удалить, у чужой с хозяином — нет
|
||||||
- [ ] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск).
|
- [x] Внутри чужой школы нет вкладки «Управление» и нет кнопок часов (пауза, скорость, пропуск).
|
||||||
Карта и люди остаются
|
Карта и люди остаются
|
||||||
- [ ] `docs/protocol.md` — списки и коды ошибок. Версию сокета не бампить
|
- [x] `docs/protocol.md` — списки и коды ошибок. Версию сокета не бампить
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Два пользователя: у каждого по две школы, третья своего — `limit-reached`; чужая в
|
- [x] Два пользователя: у каждого по две школы, третья своего — `limit-reached`; чужая в
|
||||||
`others`, не в `schools`
|
`others`, не в `schools`
|
||||||
- [ ] Восьмая школа на сервере с `MaxSchoolsTotal` = 7 — `server-full`, даже если у игрока слот есть
|
- [x] Восьмая школа на сервере с `MaxSchoolsTotal` = 7 — `server-full`, даже если у игрока слот есть
|
||||||
- [ ] Гость: `POST` найма — `403`; `SetRunning` не меняет `running` хозяина
|
- [x] Гость: `POST` найма — `403`; `SetRunning` не меняет `running` хозяина
|
||||||
- [ ] Сейв без `owner` поднимается, лежит в `others`, удаляется вторым пользователем
|
- [x] Сейв без `owner` поднимается, лежит в `others`, удаляется вторым пользователем
|
||||||
- [ ] Клиентский тест: чужая карточка без кнопки удаления; в чужой школе нет «Управление»
|
- [x] Клиентский тест: чужая карточка без кнопки удаления; в чужой школе нет «Управление»
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [39. Хозяин школы](39-school-owners.md) | 🔄 | `owner`, 2 слота на игрока, мои / чужие, гость без управления |
|
| [39. Хозяин школы](39-school-owners.md) | ✅ | `owner`, 2 слота на игрока, мои / чужие, гость без управления |
|
||||||
|
|
||||||
39 стоит на 38.
|
39 стоит на 38.
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -76,16 +76,26 @@ people; it does not change on a living school.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"maxSchools": 6,
|
"maxSchools": 2,
|
||||||
|
"maxSchoolsTotal": 16,
|
||||||
"defaultStartDate": "2012-03-31T06:00:00Z",
|
"defaultStartDate": "2012-03-31T06:00:00Z",
|
||||||
"gameMinutesPerRealSecond": 1,
|
"gameMinutesPerRealSecond": 1,
|
||||||
"schoolWeekDays": 5,
|
"schoolWeekDays": 5,
|
||||||
"schools": [
|
"schools": [
|
||||||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291 }
|
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291, "mine": true }
|
||||||
|
],
|
||||||
|
"others": [
|
||||||
|
{ "id": 2, "name": "Лицей", "gameTime": "2012-03-31T06:00:00Z", "running": true, "speedIndex": 1, "modIds": ["core"], "seed": 9912, "owner": "Leo" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`maxSchools` is how many schools **this player** may own; `maxSchoolsTotal` is how many workers the
|
||||||
|
process runs. `schools` lists yours (`mine: true` on each card). `others` lists every other save on
|
||||||
|
the server — `owner` is the display name, or `null` when the save has no owner (any logged-in player
|
||||||
|
may delete an ownerless card). The WebSocket welcome frame still carries one byte for
|
||||||
|
`maxSchools`; it now means the same per-player limit, not the process total.
|
||||||
|
|
||||||
### `GET /api/schools/random-name`
|
### `GET /api/schools/random-name`
|
||||||
|
|
||||||
`{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken.
|
`{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken.
|
||||||
@@ -197,13 +207,15 @@ the global file, so a guest watching the school draws with the same model.
|
|||||||
| `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. |
|
| `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. |
|
||||||
| `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. |
|
| `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. |
|
||||||
| `400` `invalid-portrait-settings` | `portraitSettings` failed validation (empty presets, bad age rule, out of range). |
|
| `400` `invalid-portrait-settings` | `portraitSettings` failed validation (empty presets, bad age rule, out of range). |
|
||||||
| `409` `school-limit-reached` | `maxSchools` schools already exist. |
|
| `409` `school-limit-reached` | This player already owns `maxSchools` schools. |
|
||||||
|
| `409` `server-full` | The process already runs `maxSchoolsTotal` schools. |
|
||||||
|
|
||||||
Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
|
Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
|
||||||
|
|
||||||
### `DELETE /api/schools/{id}`
|
### `DELETE /api/schools/{id}`
|
||||||
|
|
||||||
`204` when deleted, `404` when the id is unknown. Anyone watching that school over a WebSocket
|
`204` when deleted, `404` when the id is unknown, `403` `not-owner` when the school belongs to
|
||||||
|
another player. Ownerless saves may be deleted by any logged-in session. Anyone watching that school over a WebSocket
|
||||||
gets a `SchoolGone` frame.
|
gets a `SchoolGone` frame.
|
||||||
|
|
||||||
### `GET /api/schools/{id}/people`
|
### `GET /api/schools/{id}/people`
|
||||||
@@ -497,7 +509,8 @@ is off or unreachable, `connected` is false and the lists are empty.
|
|||||||
|
|
||||||
### `GET /api/schools/{id}/dress-rules`
|
### `GET /api/schools/{id}/dress-rules`
|
||||||
|
|
||||||
Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`.
|
Student and staff dress-code pairs for the school. Only the owner may read this (`403` `not-owner`).
|
||||||
|
Unknown `{id}` is `404` `unknown-school`.
|
||||||
|
|
||||||
`form` is one of `regular`, `short`, `strict`. `color` is one of `noBright`, `whiteTopBlackBottom`,
|
`form` is one of `regular`, `short`, `strict`. `color` is one of `noBright`, `whiteTopBlackBottom`,
|
||||||
`free`. When a `POST` has been accepted but not yet applied, `pendingStudents` and/or `pendingStaff`
|
`free`. When a `POST` has been accepted but not yet applied, `pendingStudents` and/or `pendingStaff`
|
||||||
@@ -515,7 +528,7 @@ not immediately.
|
|||||||
|
|
||||||
### `POST /api/schools/{id}/dress-rules`
|
### `POST /api/schools/{id}/dress-rules`
|
||||||
|
|
||||||
Queues a change for the next work morning. Either or both of `students` and `staff` may be sent;
|
Queues a change for the next work morning. Only the owner may post (`403` `not-owner`). Either or both of `students` and `staff` may be sent;
|
||||||
omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`. Unknown `form` or
|
omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`. Unknown `form` or
|
||||||
`color` is `400` `unknown-form` / `400` `unknown-color`. Response body matches `GET`.
|
`color` is `400` `unknown-form` / `400` `unknown-color`. Response body matches `GET`.
|
||||||
|
|
||||||
@@ -525,7 +538,8 @@ omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`.
|
|||||||
|
|
||||||
### `GET /api/schools/{id}/staffing`
|
### `GET /api/schools/{id}/staffing`
|
||||||
|
|
||||||
Money, uncovered subjects, the applicant pool and current staff. Reads the **published**
|
Money, uncovered subjects, the applicant pool and current staff. Only the owner may read this
|
||||||
|
(`403` `not-owner`). Reads the **published**
|
||||||
roster, applicant snapshot and catalog — it does not post to the worker. Unknown `{id}` is
|
roster, applicant snapshot and catalog — it does not post to the worker. Unknown `{id}` is
|
||||||
`404` `unknown-school`. `?lang=ru|en` labels subjects and positions.
|
`404` `unknown-school`. `?lang=ru|en` labels subjects and positions.
|
||||||
|
|
||||||
@@ -619,6 +633,7 @@ of that kind.
|
|||||||
|
|
||||||
| Status | `code` | When |
|
| Status | `code` | When |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `403` | `not-owner` | The session is not the school's owner. |
|
||||||
| `404` | `unknown-school` | No school with that id. |
|
| `404` | `unknown-school` | No school with that id. |
|
||||||
| `404` | `unknown-applicant` | `personId` is not in the pool. |
|
| `404` | `unknown-applicant` | `personId` is not in the pool. |
|
||||||
| `409` | `already-hired` | That person is already staff. |
|
| `409` | `already-hired` | That person is already staff. |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ if (headless)
|
|||||||
.WithEnvironment("Simulation__SavesDirectory", saves)
|
.WithEnvironment("Simulation__SavesDirectory", saves)
|
||||||
.WithEnvironment("HSchool__AllowSaveReload", "true")
|
.WithEnvironment("HSchool__AllowSaveReload", "true")
|
||||||
.WithEnvironment("HSchool__AlphaPassword", "test-alpha")
|
.WithEnvironment("HSchool__AlphaPassword", "test-alpha")
|
||||||
|
.WithEnvironment("Simulation__MaxSchoolsTotal", "7")
|
||||||
.WithEnvironment("SwarmUi__BaseUrl", "");
|
.WithEnvironment("SwarmUi__BaseUrl", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const ru = {
|
|||||||
sessionLogout: 'Выйти',
|
sessionLogout: 'Выйти',
|
||||||
|
|
||||||
schoolsTitle: 'Школы',
|
schoolsTitle: 'Школы',
|
||||||
|
schoolsMine: 'Мои',
|
||||||
|
schoolsOthers: 'Чужие',
|
||||||
|
schoolOwner: 'Хозяин: {owner}',
|
||||||
|
schoolOwnerless: '—',
|
||||||
createSchool: 'Создать школу',
|
createSchool: 'Создать школу',
|
||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
@@ -376,6 +380,10 @@ const en: Messages = {
|
|||||||
sessionLogout: 'Sign out',
|
sessionLogout: 'Sign out',
|
||||||
|
|
||||||
schoolsTitle: 'Schools',
|
schoolsTitle: 'Schools',
|
||||||
|
schoolsMine: 'Mine',
|
||||||
|
schoolsOthers: 'Others',
|
||||||
|
schoolOwner: 'Owner: {owner}',
|
||||||
|
schoolOwnerless: '—',
|
||||||
createSchool: 'Create school',
|
createSchool: 'Create school',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
|
|||||||
@@ -13,14 +13,29 @@ export interface School {
|
|||||||
readonly modIds?: readonly string[];
|
readonly modIds?: readonly string[];
|
||||||
/** Roster generator seed. Independent of `id`; share it to recreate the same people. */
|
/** Roster generator seed. Independent of `id`; share it to recreate the same people. */
|
||||||
readonly seed: number;
|
readonly seed: number;
|
||||||
|
readonly mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OtherSchool {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly gameTime: string;
|
||||||
|
readonly running: boolean;
|
||||||
|
readonly speedIndex: number;
|
||||||
|
readonly modIds?: readonly string[];
|
||||||
|
readonly seed: number;
|
||||||
|
/** Display name of the owner, or null when ownerless. */
|
||||||
|
readonly owner: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchoolsResponse {
|
export interface SchoolsResponse {
|
||||||
readonly maxSchools: number;
|
readonly maxSchools: number;
|
||||||
|
readonly maxSchoolsTotal: number;
|
||||||
readonly defaultStartDate: string;
|
readonly defaultStartDate: string;
|
||||||
readonly gameMinutesPerRealSecond: number;
|
readonly gameMinutesPerRealSecond: number;
|
||||||
readonly schoolWeekDays: number;
|
readonly schoolWeekDays: number;
|
||||||
readonly schools: readonly School[];
|
readonly schools: readonly School[];
|
||||||
|
readonly others: readonly OtherSchool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A failed request, with the machine-readable `code` the server puts in its problem details. */
|
/** A failed request, with the machine-readable `code` the server puts in its problem details. */
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ function school(): School {
|
|||||||
running: false,
|
running: false,
|
||||||
speedIndex: 0,
|
speedIndex: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
|
mine: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface ElementOptions {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
autocomplete?: string;
|
||||||
|
maxlength?: string;
|
||||||
dataset?: Record<string, string>;
|
dataset?: Record<string, string>;
|
||||||
onClick?: (event: Event) => void;
|
onClick?: (event: Event) => void;
|
||||||
onInput?: (event: Event) => void;
|
onInput?: (event: Event) => void;
|
||||||
@@ -44,6 +46,14 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
|||||||
(element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
|
(element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.autocomplete !== undefined && 'autocomplete' in element) {
|
||||||
|
element.setAttribute('autocomplete', options.autocomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.maxlength !== undefined && 'maxLength' in element) {
|
||||||
|
(element as HTMLInputElement).maxLength = Number.parseInt(options.maxlength, 10);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.value !== undefined && 'value' in element) {
|
if (options.value !== undefined && 'value' in element) {
|
||||||
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
|
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,28 @@ describe('GameScreen speed buttons', () => {
|
|||||||
|
|
||||||
expect(labels).toEqual(['×½', '×1', '×2', '×5', '×10']);
|
expect(labels).toEqual(['×½', '×1', '×2', '×5', '×10']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides management and clock controls for a guest school', () => {
|
||||||
|
const screen = new GameScreen({
|
||||||
|
onLeave: () => {},
|
||||||
|
onSetRunning: () => {},
|
||||||
|
onSetSpeed: () => {},
|
||||||
|
onSkip: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.append(screen.element);
|
||||||
|
screen.show({
|
||||||
|
id: 3,
|
||||||
|
name: 'Foreign',
|
||||||
|
gameTime: '2012-03-31T06:00:00.000Z',
|
||||||
|
running: true,
|
||||||
|
speedIndex: 1,
|
||||||
|
seed: 9,
|
||||||
|
mine: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manageTab = screen.element.querySelectorAll<HTMLElement>('.mode-tab')[1];
|
||||||
|
expect((manageTab as HTMLElement).hidden).toBe(true);
|
||||||
|
expect((screen.element.querySelector('.clock__controls') as HTMLElement).hidden).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,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 canManage = true;
|
||||||
private peopleSeed: number | null = null;
|
private peopleSeed: number | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
private lastGameTime: Date | null = null;
|
private lastGameTime: Date | null = null;
|
||||||
@@ -209,6 +210,17 @@ 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.canManage = school.mine;
|
||||||
|
this.manageTab.hidden = !this.canManage;
|
||||||
|
if (!this.canManage) {
|
||||||
|
this.showMode('overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clockControls = this.root.querySelector<HTMLElement>('.clock__controls');
|
||||||
|
if (clockControls !== null) {
|
||||||
|
clockControls.hidden = !this.canManage;
|
||||||
|
}
|
||||||
|
|
||||||
this.schoolId = school.id;
|
this.schoolId = school.id;
|
||||||
this.peopleSeed = school.seed;
|
this.peopleSeed = school.seed;
|
||||||
this.schoolName.textContent = school.name;
|
this.schoolName.textContent = school.name;
|
||||||
@@ -311,6 +323,10 @@ export class GameScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showMode(mode: 'overview' | 'manage'): void {
|
private showMode(mode: 'overview' | 'manage'): void {
|
||||||
|
if (mode === 'manage' && !this.canManage) {
|
||||||
|
mode = 'overview';
|
||||||
|
}
|
||||||
|
|
||||||
this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview');
|
this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview');
|
||||||
this.manageTab.classList.toggle('mode-tab--active', mode === 'manage');
|
this.manageTab.classList.toggle('mode-tab--active', mode === 'manage');
|
||||||
this.overview.hidden = mode !== 'overview';
|
this.overview.hidden = mode !== 'overview';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
deleteSchool,
|
deleteSchool,
|
||||||
fetchRandomName,
|
fetchRandomName,
|
||||||
fetchSchools,
|
fetchSchools,
|
||||||
|
type OtherSchool,
|
||||||
type School,
|
type School,
|
||||||
type SchoolsResponse,
|
type SchoolsResponse,
|
||||||
} from '../net/api.ts';
|
} from '../net/api.ts';
|
||||||
@@ -11,7 +12,7 @@ import { schoolWord, t } from '../i18n/strings.ts';
|
|||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
import { confirmDialog } from './confirmDialog.ts';
|
import { confirmDialog } from './confirmDialog.ts';
|
||||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||||
import { SchoolCard } from './schoolCard.ts';
|
import { SchoolCard, type MenuSchool } from './schoolCard.ts';
|
||||||
|
|
||||||
interface MainMenuOptions {
|
interface MainMenuOptions {
|
||||||
readonly onOpenSchool: (school: School) => void;
|
readonly onOpenSchool: (school: School) => void;
|
||||||
@@ -27,10 +28,18 @@ interface MainMenuOptions {
|
|||||||
*/
|
*/
|
||||||
const REFRESH_INTERVAL_MS = 1000;
|
const REFRESH_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
interface CardEntry {
|
||||||
|
readonly card: SchoolCard;
|
||||||
|
readonly section: 'mine' | 'other';
|
||||||
|
}
|
||||||
|
|
||||||
export class MainMenu {
|
export class MainMenu {
|
||||||
private readonly root = el('section', { class: 'screen menu' });
|
private readonly root = el('section', { class: 'screen menu' });
|
||||||
private readonly title = el('h1', { class: 'screen__title' });
|
private readonly title = el('h1', { class: 'screen__title' });
|
||||||
private readonly grid = el('div', { class: 'card-grid' });
|
private readonly mineHeading = el('h2', { class: 'menu__heading' });
|
||||||
|
private readonly mineGrid = el('div', { class: 'card-grid' });
|
||||||
|
private readonly othersHeading = el('h2', { class: 'menu__heading' });
|
||||||
|
private readonly othersGrid = el('div', { class: 'card-grid' });
|
||||||
private readonly emptyHint = el('p', { class: 'hint' });
|
private readonly emptyHint = el('p', { class: 'hint' });
|
||||||
private readonly createButton = el('button', {
|
private readonly createButton = el('button', {
|
||||||
class: 'button button--primary',
|
class: 'button button--primary',
|
||||||
@@ -43,7 +52,7 @@ export class MainMenu {
|
|||||||
|
|
||||||
private readonly limitHint = el('p', { class: 'hint' });
|
private readonly limitHint = el('p', { class: 'hint' });
|
||||||
private readonly status = el('p', { class: 'hint hint--error' });
|
private readonly status = el('p', { class: 'hint hint--error' });
|
||||||
private readonly cards = new Map<number, SchoolCard>();
|
private readonly cards = new Map<number, CardEntry>();
|
||||||
|
|
||||||
private state: SchoolsResponse | null = null;
|
private state: SchoolsResponse | null = null;
|
||||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -65,7 +74,10 @@ export class MainMenu {
|
|||||||
this.limitHint,
|
this.limitHint,
|
||||||
this.status,
|
this.status,
|
||||||
this.emptyHint,
|
this.emptyHint,
|
||||||
this.grid,
|
this.mineHeading,
|
||||||
|
this.mineGrid,
|
||||||
|
this.othersHeading,
|
||||||
|
this.othersGrid,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.localize();
|
this.localize();
|
||||||
@@ -78,12 +90,14 @@ export class MainMenu {
|
|||||||
/** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */
|
/** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */
|
||||||
localize(): void {
|
localize(): void {
|
||||||
this.title.textContent = t('schoolsTitle');
|
this.title.textContent = t('schoolsTitle');
|
||||||
|
this.mineHeading.textContent = t('schoolsMine');
|
||||||
|
this.othersHeading.textContent = t('schoolsOthers');
|
||||||
this.createButton.textContent = t('createSchool');
|
this.createButton.textContent = t('createSchool');
|
||||||
this.logoutButton.textContent = t('sessionLogout');
|
this.logoutButton.textContent = t('sessionLogout');
|
||||||
this.emptyHint.textContent = t('emptySchools');
|
this.emptyHint.textContent = t('emptySchools');
|
||||||
|
|
||||||
for (const card of this.cards.values()) {
|
for (const entry of this.cards.values()) {
|
||||||
card.localize();
|
entry.card.localize();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.paintError();
|
this.paintError();
|
||||||
@@ -96,8 +110,6 @@ export class MainMenu {
|
|||||||
|
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
// No visibility check here: browsers already throttle timers in background tabs, and a
|
|
||||||
// hidden-tab guard silently freezes the list in embedded views that report themselves hidden.
|
|
||||||
this.refreshTimer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS);
|
this.refreshTimer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,35 +163,67 @@ export class MainMenu {
|
|||||||
: t('schoolCount', { current: state.schools.length, max: state.maxSchools });
|
: t('schoolCount', { current: state.schools.length, max: state.maxSchools });
|
||||||
|
|
||||||
this.emptyHint.hidden = state.schools.length > 0;
|
this.emptyHint.hidden = state.schools.length > 0;
|
||||||
|
this.othersHeading.hidden = state.others.length === 0;
|
||||||
|
this.othersGrid.hidden = state.others.length === 0;
|
||||||
|
|
||||||
// Patch the cards that are already on screen; only added and removed schools touch the DOM.
|
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|
||||||
for (const school of state.schools) {
|
for (const school of state.schools) {
|
||||||
seen.add(school.id);
|
seen.add(school.id);
|
||||||
|
this.patchCard(school, 'mine', this.mineGrid, {
|
||||||
const card = this.cards.get(school.id);
|
canDelete: true,
|
||||||
if (card === undefined) {
|
ownerLabel: null,
|
||||||
const created = new SchoolCard(school, {
|
|
||||||
onOpen: (opened) => this.options.onOpenSchool(opened),
|
|
||||||
onDelete: (target) => void this.confirmDelete(target),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.cards.set(school.id, created);
|
|
||||||
this.grid.appendChild(created.element);
|
|
||||||
} else {
|
|
||||||
card.update(school);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, card] of this.cards) {
|
for (const other of state.others) {
|
||||||
|
seen.add(other.id);
|
||||||
|
const menuSchool: MenuSchool = { ...other, mine: false };
|
||||||
|
this.patchCard(menuSchool, 'other', this.othersGrid, {
|
||||||
|
canDelete: other.owner === null,
|
||||||
|
ownerLabel: other.owner ?? t('schoolOwnerless'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, entry] of this.cards) {
|
||||||
if (!seen.has(id)) {
|
if (!seen.has(id)) {
|
||||||
card.element.remove();
|
entry.card.element.remove();
|
||||||
this.cards.delete(id);
|
this.cards.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async confirmDelete(school: School): Promise<void> {
|
private patchCard(
|
||||||
|
school: MenuSchool,
|
||||||
|
section: 'mine' | 'other',
|
||||||
|
grid: HTMLElement,
|
||||||
|
options: { canDelete: boolean; ownerLabel: string | null },
|
||||||
|
): void {
|
||||||
|
const entry = this.cards.get(school.id);
|
||||||
|
if (entry === undefined) {
|
||||||
|
const card = new SchoolCard(school, {
|
||||||
|
onOpen: (opened) => this.options.onOpenSchool(toSchool(opened)),
|
||||||
|
onDelete: (target) => void this.confirmDelete(target),
|
||||||
|
canDelete: options.canDelete,
|
||||||
|
ownerLabel: options.ownerLabel,
|
||||||
|
});
|
||||||
|
this.cards.set(school.id, { card, section });
|
||||||
|
grid.appendChild(card.element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.section !== section) {
|
||||||
|
entry.card.element.remove();
|
||||||
|
grid.appendChild(entry.card.element);
|
||||||
|
this.cards.set(school.id, { card: entry.card, section });
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.card.setOwnerLabel(options.ownerLabel);
|
||||||
|
entry.card.setCanDelete(options.canDelete);
|
||||||
|
entry.card.update(school);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async confirmDelete(school: MenuSchool): Promise<void> {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t('deleteSchoolTitle'),
|
title: t('deleteSchoolTitle'),
|
||||||
message: t('deleteSchoolMessage', { name: school.name }),
|
message: t('deleteSchoolMessage', { name: school.name }),
|
||||||
@@ -220,3 +264,12 @@ export class MainMenu {
|
|||||||
await this.refresh();
|
await this.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSchool(school: MenuSchool): School {
|
||||||
|
if ('mine' in school && school.mine) {
|
||||||
|
return school;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { owner: _owner, ...rest } = school as OtherSchool & { mine: false };
|
||||||
|
return { ...rest, mine: false };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SchoolCard } from './schoolCard.ts';
|
||||||
|
|
||||||
|
describe('SchoolCard ownership', () => {
|
||||||
|
it('hides delete on another owners school card', () => {
|
||||||
|
const card = new SchoolCard(
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Foreign',
|
||||||
|
gameTime: '2012-03-31T06:00:00.000Z',
|
||||||
|
running: true,
|
||||||
|
speedIndex: 1,
|
||||||
|
seed: 1,
|
||||||
|
mine: false,
|
||||||
|
owner: 'Bob',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onOpen: () => {},
|
||||||
|
onDelete: () => {},
|
||||||
|
canDelete: false,
|
||||||
|
ownerLabel: 'Bob',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
document.body.append(card.element);
|
||||||
|
|
||||||
|
expect((card.element.querySelector('.button--danger') as HTMLElement).hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import type { School } from '../net/api.ts';
|
import type { OtherSchool, School } from '../net/api.ts';
|
||||||
import { formatGameDateTime } from '../format/gameTime.ts';
|
import { formatGameDateTime } from '../format/gameTime.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
|
|
||||||
|
export type MenuSchool = School | (OtherSchool & { mine: false });
|
||||||
|
|
||||||
interface SchoolCardOptions {
|
interface SchoolCardOptions {
|
||||||
readonly onOpen: (school: School) => void;
|
readonly onOpen: (school: MenuSchool) => void;
|
||||||
readonly onDelete: (school: School) => void;
|
readonly onDelete: (school: MenuSchool) => void;
|
||||||
|
readonly canDelete: boolean;
|
||||||
|
readonly ownerLabel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +20,7 @@ export class SchoolCard {
|
|||||||
readonly element = el('article', { class: 'card' });
|
readonly element = el('article', { class: 'card' });
|
||||||
|
|
||||||
private readonly title = el('h2', { class: 'card__title' });
|
private readonly title = el('h2', { class: 'card__title' });
|
||||||
|
private readonly owner = el('p', { class: 'card__owner hint' });
|
||||||
private readonly time = el('p', { class: 'card__time' });
|
private readonly time = el('p', { class: 'card__time' });
|
||||||
private readonly pausedBadge = el('span', { class: 'card__badge' });
|
private readonly pausedBadge = el('span', { class: 'card__badge' });
|
||||||
private readonly deleteButton = el('button', {
|
private readonly deleteButton = el('button', {
|
||||||
@@ -23,13 +28,14 @@ export class SchoolCard {
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
});
|
});
|
||||||
|
|
||||||
private school: School;
|
private school: MenuSchool;
|
||||||
|
|
||||||
constructor(school: School, options: SchoolCardOptions) {
|
constructor(school: MenuSchool, options: SchoolCardOptions) {
|
||||||
this.school = school;
|
this.school = school;
|
||||||
|
|
||||||
this.element.dataset['schoolId'] = String(school.id);
|
this.element.dataset['schoolId'] = String(school.id);
|
||||||
this.element.tabIndex = 0;
|
this.element.tabIndex = 0;
|
||||||
|
this.deleteButton.hidden = !options.canDelete;
|
||||||
this.deleteButton.addEventListener('click', (event) => {
|
this.deleteButton.addEventListener('click', (event) => {
|
||||||
// The whole card is clickable, so the delete button must not open the school too.
|
// The whole card is clickable, so the delete button must not open the school too.
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -38,6 +44,7 @@ export class SchoolCard {
|
|||||||
|
|
||||||
this.element.append(
|
this.element.append(
|
||||||
this.title,
|
this.title,
|
||||||
|
this.owner,
|
||||||
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
|
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
|
||||||
el('div', { class: 'card__actions' }, this.deleteButton),
|
el('div', { class: 'card__actions' }, this.deleteButton),
|
||||||
);
|
);
|
||||||
@@ -50,6 +57,7 @@ export class SchoolCard {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.setOwnerLabel(options.ownerLabel ?? null);
|
||||||
this.localize();
|
this.localize();
|
||||||
this.update(school);
|
this.update(school);
|
||||||
}
|
}
|
||||||
@@ -60,11 +68,26 @@ export class SchoolCard {
|
|||||||
this.update(this.school);
|
this.update(this.school);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(school: School): void {
|
setOwnerLabel(label: string | null): void {
|
||||||
|
if (label === null) {
|
||||||
|
this.owner.hidden = true;
|
||||||
|
this.owner.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.hidden = false;
|
||||||
|
this.owner.textContent = t('schoolOwner', { owner: label });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(school: MenuSchool): void {
|
||||||
this.school = school;
|
this.school = school;
|
||||||
|
|
||||||
this.title.textContent = school.name;
|
this.title.textContent = school.name;
|
||||||
this.time.textContent = formatGameDateTime(new Date(school.gameTime));
|
this.time.textContent = formatGameDateTime(new Date(school.gameTime));
|
||||||
this.pausedBadge.hidden = school.running;
|
this.pausedBadge.hidden = school.running;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCanDelete(canDelete: boolean): void {
|
||||||
|
this.deleteButton.hidden = !canDelete;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ApiError, fetchSchools, fetchSession } from '../net/api.ts';
|
import { ApiError, fetchSession } from '../net/api.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import { ensureSession } from './sessionGate.ts';
|
import { ensureSession } from './sessionGate.ts';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using HSchool.Server.Game;
|
||||||
|
using HSchool.Server.Session;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Api;
|
||||||
|
|
||||||
|
internal static class SchoolAccess
|
||||||
|
{
|
||||||
|
public static bool TryGetNormalizedUser(HttpContext context, SessionService sessions, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = "";
|
||||||
|
return sessions.TryGetUserName(context, out var userName)
|
||||||
|
&& SchoolNames.TryNormalize(userName, out normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IResult? RequireManage(GameLoopService loop, int schoolId, string normalizedUserName)
|
||||||
|
{
|
||||||
|
var school = loop.FindSchool(schoolId);
|
||||||
|
if (school is null)
|
||||||
|
{
|
||||||
|
return NotFoundSchool();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SchoolOwnership.CanManage(school, normalizedUserName))
|
||||||
|
{
|
||||||
|
return NotOwner();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? DisplayOwner(UserStore users, string? normalizedOwner)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedOwner))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.TryFindCanonical(normalizedOwner, out var display) ? display : normalizedOwner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IResult NotOwner() =>
|
||||||
|
Problem(StatusCodes.Status403Forbidden, "not-owner", "You do not own this school.");
|
||||||
|
|
||||||
|
public static IResult NotFoundSchool() =>
|
||||||
|
Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||||
|
|
||||||
|
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||||
|
Results.Problem(
|
||||||
|
detail: detail,
|
||||||
|
statusCode: statusCode,
|
||||||
|
title: code,
|
||||||
|
extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
using HSchool.Server.Game;
|
using HSchool.Server.Game;
|
||||||
|
using HSchool.Server.Session;
|
||||||
using HSchool.Simulation;
|
using HSchool.Simulation;
|
||||||
|
|
||||||
namespace HSchool.Server.Api;
|
namespace HSchool.Server.Api;
|
||||||
@@ -19,17 +20,37 @@ internal static class SchoolEndpoints
|
|||||||
{
|
{
|
||||||
var schools = builder.MapGroup("/api/schools");
|
var schools = builder.MapGroup("/api/schools");
|
||||||
|
|
||||||
schools.MapGet("/", (GameLoopService loop) =>
|
schools.MapGet("/", (HttpContext context, GameLoopService loop, SessionService sessions, UserStore users) =>
|
||||||
{
|
{
|
||||||
var state = loop.SchoolsState;
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
var options = loop.Options;
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
return new SchoolsResponse(
|
var options = loop.Options;
|
||||||
state.MaxSchools,
|
var mine = new List<SchoolResponse>();
|
||||||
|
var others = new List<OtherSchoolResponse>();
|
||||||
|
|
||||||
|
foreach (var school in loop.SchoolsState.Schools)
|
||||||
|
{
|
||||||
|
if (SchoolOwnership.IsOwner(school, normalizedUser))
|
||||||
|
{
|
||||||
|
mine.Add(SchoolResponse.From(school, mine: true));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
others.Add(OtherSchoolResponse.From(school, SchoolAccess.DisplayOwner(users, school.Owner)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new SchoolsResponse(
|
||||||
|
options.MaxSchools,
|
||||||
|
options.MaxSchoolsTotal,
|
||||||
options.DefaultStartDate,
|
options.DefaultStartDate,
|
||||||
options.GameMinutesPerRealSecond,
|
options.GameMinutesPerRealSecond,
|
||||||
options.SchoolWeekDays,
|
options.SchoolWeekDays,
|
||||||
[.. state.Schools.Select(SchoolResponse.From)]);
|
mine,
|
||||||
|
others));
|
||||||
})
|
})
|
||||||
.WithName("GetSchools");
|
.WithName("GetSchools");
|
||||||
|
|
||||||
@@ -45,9 +66,16 @@ internal static class SchoolEndpoints
|
|||||||
|
|
||||||
schools.MapPost("/", async (
|
schools.MapPost("/", async (
|
||||||
CreateSchoolRequest request,
|
CreateSchoolRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var owner))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
SwarmUiConfigFile? portraitSettings = null;
|
SwarmUiConfigFile? portraitSettings = null;
|
||||||
if (request.PortraitSettings is not null)
|
if (request.PortraitSettings is not null)
|
||||||
{
|
{
|
||||||
@@ -70,6 +98,7 @@ internal static class SchoolEndpoints
|
|||||||
request.CountryId,
|
request.CountryId,
|
||||||
request.NativeLanguage,
|
request.NativeLanguage,
|
||||||
request.Seed,
|
request.Seed,
|
||||||
|
owner,
|
||||||
portraitSettings,
|
portraitSettings,
|
||||||
NewCompletion<SchoolCreationOutcome>());
|
NewCompletion<SchoolCreationOutcome>());
|
||||||
commands.Enqueue(command);
|
commands.Enqueue(command);
|
||||||
@@ -79,9 +108,11 @@ internal static class SchoolEndpoints
|
|||||||
return outcome.Error switch
|
return outcome.Error switch
|
||||||
{
|
{
|
||||||
SchoolCreationError.None =>
|
SchoolCreationError.None =>
|
||||||
Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School)),
|
Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School, mine: true)),
|
||||||
SchoolCreationError.LimitReached =>
|
SchoolCreationError.LimitReached =>
|
||||||
Problem(StatusCodes.Status409Conflict, "school-limit-reached", "The school limit is already reached."),
|
Problem(StatusCodes.Status409Conflict, "school-limit-reached", "Your school limit is already reached."),
|
||||||
|
SchoolCreationError.ServerFull =>
|
||||||
|
Problem(StatusCodes.Status409Conflict, "server-full", "The server cannot host any more schools."),
|
||||||
SchoolCreationError.InvalidName =>
|
SchoolCreationError.InvalidName =>
|
||||||
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
|
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
|
||||||
SchoolCreationError.InvalidStartDate =>
|
SchoolCreationError.InvalidStartDate =>
|
||||||
@@ -111,9 +142,28 @@ internal static class SchoolEndpoints
|
|||||||
|
|
||||||
schools.MapDelete("/{id:int}", async (
|
schools.MapDelete("/{id:int}", async (
|
||||||
int id,
|
int id,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
|
GameLoopService loop,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var school = loop.FindSchool(id);
|
||||||
|
if (school is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SchoolOwnership.CanDelete(school, normalizedUser))
|
||||||
|
{
|
||||||
|
return SchoolAccess.NotOwner();
|
||||||
|
}
|
||||||
|
|
||||||
var command = new GameCommand.DeleteSchool(id, NewCompletion<bool>());
|
var command = new GameCommand.DeleteSchool(id, NewCompletion<bool>());
|
||||||
commands.Enqueue(command);
|
commands.Enqueue(command);
|
||||||
|
|
||||||
@@ -371,9 +421,23 @@ internal static class SchoolEndpoints
|
|||||||
|
|
||||||
schools.MapGet("/{id:int}/dress-rules", async (
|
schools.MapGet("/{id:int}/dress-rules", async (
|
||||||
int id,
|
int id,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
|
GameLoopService loop,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
var command = new GameCommand.GetDressRules(id, NewCompletion<DressRulesOutcome>());
|
var command = new GameCommand.GetDressRules(id, NewCompletion<DressRulesOutcome>());
|
||||||
commands.Enqueue(command);
|
commands.Enqueue(command);
|
||||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||||
@@ -384,9 +448,23 @@ internal static class SchoolEndpoints
|
|||||||
schools.MapPost("/{id:int}/dress-rules", async (
|
schools.MapPost("/{id:int}/dress-rules", async (
|
||||||
int id,
|
int id,
|
||||||
SetDressRulesRequest request,
|
SetDressRulesRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
|
GameLoopService loop,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
DressRulePair? students = null;
|
DressRulePair? students = null;
|
||||||
if (request.Students is { } studentDto)
|
if (request.Students is { } studentDto)
|
||||||
{
|
{
|
||||||
@@ -419,8 +497,21 @@ internal static class SchoolEndpoints
|
|||||||
schools.MapGet("/{id:int}/staffing", (
|
schools.MapGet("/{id:int}/staffing", (
|
||||||
int id,
|
int id,
|
||||||
string? lang,
|
string? lang,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameLoopService loop) =>
|
GameLoopService loop) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
var published = loop.FindPeople(id);
|
var published = loop.FindPeople(id);
|
||||||
if (published is null)
|
if (published is null)
|
||||||
{
|
{
|
||||||
@@ -435,10 +526,23 @@ internal static class SchoolEndpoints
|
|||||||
int id,
|
int id,
|
||||||
HireStaffRequest request,
|
HireStaffRequest request,
|
||||||
string? lang,
|
string? lang,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
GameLoopService loop,
|
GameLoopService loop,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryPersonId(request.PersonId, out var personId, out var error)
|
if (!TryPersonId(request.PersonId, out var personId, out var error)
|
||||||
|| !TryDefName(request.Position, "position", out var position, out error))
|
|| !TryDefName(request.Position, "position", out var position, out error))
|
||||||
{
|
{
|
||||||
@@ -457,10 +561,23 @@ internal static class SchoolEndpoints
|
|||||||
string personId,
|
string personId,
|
||||||
AssignSubjectRequest request,
|
AssignSubjectRequest request,
|
||||||
string? lang,
|
string? lang,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
GameLoopService loop,
|
GameLoopService loop,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryPersonId(personId, out var idValue, out var error)
|
if (!TryPersonId(personId, out var idValue, out var error)
|
||||||
|| !TryDefName(request.Subject, "subject", out var subject, out error))
|
|| !TryDefName(request.Subject, "subject", out var subject, out error))
|
||||||
{
|
{
|
||||||
@@ -479,10 +596,23 @@ internal static class SchoolEndpoints
|
|||||||
string personId,
|
string personId,
|
||||||
string subject,
|
string subject,
|
||||||
string? lang,
|
string? lang,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
GameLoopService loop,
|
GameLoopService loop,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryPersonId(personId, out var idValue, out var error)
|
if (!TryPersonId(personId, out var idValue, out var error)
|
||||||
|| !TryDefName(subject, "subject", out var subjectName, out error))
|
|| !TryDefName(subject, "subject", out var subjectName, out error))
|
||||||
{
|
{
|
||||||
@@ -827,18 +957,35 @@ internal sealed record SchoolResponse(
|
|||||||
bool Running,
|
bool Running,
|
||||||
byte SpeedIndex,
|
byte SpeedIndex,
|
||||||
IReadOnlyList<string> ModIds,
|
IReadOnlyList<string> ModIds,
|
||||||
int Seed)
|
int Seed,
|
||||||
|
bool Mine)
|
||||||
{
|
{
|
||||||
public static SchoolResponse From(SchoolState school) =>
|
public static SchoolResponse From(SchoolState school, bool mine) =>
|
||||||
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds, school.Seed);
|
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds, school.Seed, mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record OtherSchoolResponse(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
DateTime GameTime,
|
||||||
|
bool Running,
|
||||||
|
byte SpeedIndex,
|
||||||
|
IReadOnlyList<string> ModIds,
|
||||||
|
int Seed,
|
||||||
|
string? Owner)
|
||||||
|
{
|
||||||
|
public static OtherSchoolResponse From(SchoolState school, string? ownerDisplay) =>
|
||||||
|
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds, school.Seed, ownerDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Everything the main menu needs in one request.</summary>
|
/// <summary>Everything the main menu needs in one request.</summary>
|
||||||
internal sealed record SchoolsResponse(
|
internal sealed record SchoolsResponse(
|
||||||
int MaxSchools,
|
int MaxSchools,
|
||||||
|
int MaxSchoolsTotal,
|
||||||
DateTime DefaultStartDate,
|
DateTime DefaultStartDate,
|
||||||
double GameMinutesPerRealSecond,
|
double GameMinutesPerRealSecond,
|
||||||
int SchoolWeekDays,
|
int SchoolWeekDays,
|
||||||
IReadOnlyList<SchoolResponse> Schools);
|
IReadOnlyList<SchoolResponse> Schools,
|
||||||
|
IReadOnlyList<OtherSchoolResponse> Others);
|
||||||
|
|
||||||
internal sealed record RandomNameResponse(string Name);
|
internal sealed record RandomNameResponse(string Name);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
using HSchool.Schedule;
|
using HSchool.Schedule;
|
||||||
using HSchool.Server.Game;
|
using HSchool.Server.Game;
|
||||||
|
using HSchool.Server.Session;
|
||||||
|
|
||||||
namespace HSchool.Server.Api;
|
namespace HSchool.Server.Api;
|
||||||
|
|
||||||
@@ -33,10 +34,23 @@ internal static class TimetableEndpoints
|
|||||||
int id,
|
int id,
|
||||||
PinLessonRequest request,
|
PinLessonRequest request,
|
||||||
string? lang,
|
string? lang,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
GameLoopService loop,
|
GameLoopService loop,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryDefName(request.ClassId, "classId", out var classId, out var error)
|
if (!TryDefName(request.ClassId, "classId", out var classId, out var error)
|
||||||
|| !TryDefName(request.Subject, "subject", out var subject, out error)
|
|| !TryDefName(request.Subject, "subject", out var subject, out error)
|
||||||
|| !TryDefName(request.RoomId, "roomId", out var roomId, out error))
|
|| !TryDefName(request.RoomId, "roomId", out var roomId, out error))
|
||||||
@@ -65,10 +79,23 @@ internal static class TimetableEndpoints
|
|||||||
int? day,
|
int? day,
|
||||||
int? period,
|
int? period,
|
||||||
string? lang,
|
string? lang,
|
||||||
|
HttpContext context,
|
||||||
|
SessionService sessions,
|
||||||
GameCommandQueue commands,
|
GameCommandQueue commands,
|
||||||
GameLoopService loop,
|
GameLoopService loop,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var denied = SchoolAccess.RequireManage(loop, id, normalizedUser);
|
||||||
|
if (denied is not null)
|
||||||
|
{
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryDefName(classId, "classId", out var classValue, out var error)
|
if (!TryDefName(classId, "classId", out var classValue, out var error)
|
||||||
|| !TryDefName(subject, "subject", out var subjectValue, out error)
|
|| !TryDefName(subject, "subject", out var subjectValue, out error)
|
||||||
|| day is null
|
|| day is null
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal abstract record GameCommand
|
|||||||
string? CountryId,
|
string? CountryId,
|
||||||
string? NativeLanguage,
|
string? NativeLanguage,
|
||||||
int? Seed,
|
int? Seed,
|
||||||
|
string Owner,
|
||||||
SwarmUiConfigFile? PortraitSettings,
|
SwarmUiConfigFile? PortraitSettings,
|
||||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||||
|
|
||||||
@@ -35,11 +36,11 @@ internal abstract record GameCommand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
|
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
|
||||||
|
|
||||||
internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
|
internal sealed record SetRunning(uint PlayerId, bool Running, string NormalizedUserName) : GameCommand;
|
||||||
|
|
||||||
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
|
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex, string NormalizedUserName) : GameCommand;
|
||||||
|
|
||||||
internal sealed record SkipEmpty(uint PlayerId) : GameCommand;
|
internal sealed record SkipEmpty(uint PlayerId, string NormalizedUserName) : GameCommand;
|
||||||
|
|
||||||
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
|
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
|
||||||
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
|
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ internal sealed class GameLoopService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SchoolState? FindSchool(int schoolId)
|
||||||
|
{
|
||||||
|
foreach (var worker in Volatile.Read(ref _publishedWorkers))
|
||||||
|
{
|
||||||
|
if (worker.Id == schoolId)
|
||||||
|
{
|
||||||
|
return worker.Snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_incompatible.TryGetValue(schoolId, out var broken))
|
||||||
|
{
|
||||||
|
return broken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CountOwnedBy(string normalizedUserName)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var school in SchoolsState.Schools)
|
||||||
|
{
|
||||||
|
if (SchoolOwnership.IsOwner(school, normalizedUserName))
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public Task ReloadFromDiskAsync(CancellationToken cancellationToken)
|
public Task ReloadFromDiskAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -109,8 +141,9 @@ internal sealed class GameLoopService(
|
|||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"School supervisor starting at {TickRate} Hz, up to {MaxSchools} schools, saves in {Directory}.",
|
"School supervisor starting at {TickRate} Hz, up to {MaxSchoolsTotal} schools ({MaxSchools} per player), saves in {Directory}.",
|
||||||
_options.TickRate,
|
_options.TickRate,
|
||||||
|
_options.MaxSchoolsTotal,
|
||||||
_options.MaxSchools,
|
_options.MaxSchools,
|
||||||
store.DirectoryPath);
|
store.DirectoryPath);
|
||||||
|
|
||||||
@@ -181,15 +214,15 @@ internal sealed class GameLoopService(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case GameCommand.SetRunning setRunning:
|
case GameCommand.SetRunning setRunning:
|
||||||
RouteOpenSchool(setRunning.PlayerId, new WorkerCommand.SetRunning(setRunning.Running));
|
HandleClockCommand(setRunning.PlayerId, setRunning.NormalizedUserName, new WorkerCommand.SetRunning(setRunning.Running));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case GameCommand.SetSpeed setSpeed:
|
case GameCommand.SetSpeed setSpeed:
|
||||||
RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
|
HandleClockCommand(setSpeed.PlayerId, setSpeed.NormalizedUserName, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case GameCommand.SkipEmpty skipEmpty:
|
case GameCommand.SkipEmpty skipEmpty:
|
||||||
RouteOpenSchool(skipEmpty.PlayerId, new WorkerCommand.SkipEmpty());
|
HandleClockCommand(skipEmpty.PlayerId, skipEmpty.NormalizedUserName, new WorkerCommand.SkipEmpty());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case GameCommand.ReloadSaves reload:
|
case GameCommand.ReloadSaves reload:
|
||||||
@@ -341,7 +374,13 @@ internal sealed class GameLoopService(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_workers.Count >= _options.MaxSchools)
|
if (_workers.Count >= _options.MaxSchoolsTotal)
|
||||||
|
{
|
||||||
|
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.ServerFull));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CountOwnedBy(command.Owner) >= _options.MaxSchools)
|
||||||
{
|
{
|
||||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.LimitReached));
|
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.LimitReached));
|
||||||
return;
|
return;
|
||||||
@@ -443,6 +482,7 @@ internal sealed class GameLoopService(
|
|||||||
climatePresetId,
|
climatePresetId,
|
||||||
nativeLanguage,
|
nativeLanguage,
|
||||||
seed,
|
seed,
|
||||||
|
owner: command.Owner,
|
||||||
portraitSettings: portrait);
|
portraitSettings: portrait);
|
||||||
Track(worker);
|
Track(worker);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
@@ -587,6 +627,23 @@ internal sealed class GameLoopService(
|
|||||||
Route(schoolId, command);
|
Route(schoolId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleClockCommand(uint playerId, string normalizedUserName, WorkerCommand command)
|
||||||
|
{
|
||||||
|
var client = clients.Find(playerId);
|
||||||
|
if (client?.OpenSchoolId is not { } schoolId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_workers.TryGetValue(schoolId, out var worker)
|
||||||
|
|| !SchoolOwnership.CanManage(worker.Snapshot, normalizedUserName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route(schoolId, command);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task StartWorkersFromDiskAsync()
|
private async Task StartWorkersFromDiskAsync()
|
||||||
{
|
{
|
||||||
var saves = store.LoadAll();
|
var saves = store.LoadAll();
|
||||||
@@ -611,19 +668,19 @@ internal sealed class GameLoopService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startable.Count > _options.MaxSchools)
|
if (startable.Count > _options.MaxSchoolsTotal)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Found {Count} runnable school saves but the limit is {Max}; starting the first {Max}.",
|
"Found {Count} runnable school saves but the limit is {Max}; starting the first {Max}.",
|
||||||
startable.Count,
|
startable.Count,
|
||||||
_options.MaxSchools,
|
_options.MaxSchoolsTotal,
|
||||||
_options.MaxSchools);
|
_options.MaxSchoolsTotal);
|
||||||
foreach (var extra in startable.Skip(_options.MaxSchools))
|
foreach (var extra in startable.Skip(_options.MaxSchoolsTotal))
|
||||||
{
|
{
|
||||||
RememberIncompatible(FromSave(extra));
|
RememberIncompatible(FromSave(extra));
|
||||||
}
|
}
|
||||||
|
|
||||||
startable = [.. startable.Take(_options.MaxSchools)];
|
startable = [.. startable.Take(_options.MaxSchoolsTotal)];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var save in startable)
|
foreach (var save in startable)
|
||||||
@@ -643,6 +700,7 @@ internal sealed class GameLoopService(
|
|||||||
createSeed: null,
|
createSeed: null,
|
||||||
save.Presence,
|
save.Presence,
|
||||||
save.DressRules,
|
save.DressRules,
|
||||||
|
save.Owner,
|
||||||
save.PortraitSettings);
|
save.PortraitSettings);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -700,6 +758,7 @@ internal sealed class GameLoopService(
|
|||||||
int? createSeed = null,
|
int? createSeed = null,
|
||||||
IReadOnlyList<PresenceSnapshot>? presence = null,
|
IReadOnlyList<PresenceSnapshot>? presence = null,
|
||||||
SchoolDressRules? dressRules = null,
|
SchoolDressRules? dressRules = null,
|
||||||
|
string? owner = null,
|
||||||
SwarmUiConfigFile? portraitSettings = null) =>
|
SwarmUiConfigFile? portraitSettings = null) =>
|
||||||
new(
|
new(
|
||||||
id,
|
id,
|
||||||
@@ -716,6 +775,7 @@ internal sealed class GameLoopService(
|
|||||||
createSeed,
|
createSeed,
|
||||||
presence,
|
presence,
|
||||||
dressRules,
|
dressRules,
|
||||||
|
owner,
|
||||||
portraitSettings,
|
portraitSettings,
|
||||||
_options,
|
_options,
|
||||||
clients,
|
clients,
|
||||||
@@ -792,6 +852,7 @@ internal sealed class GameLoopService(
|
|||||||
(byte)Math.Clamp(save.SpeedIndex, 0, 255),
|
(byte)Math.Clamp(save.SpeedIndex, 0, 255),
|
||||||
save.ModIds ?? [],
|
save.ModIds ?? [],
|
||||||
SeedOf(save.Id),
|
SeedOf(save.Id),
|
||||||
|
Owner: string.IsNullOrWhiteSpace(save.Owner) ? null : save.Owner,
|
||||||
Incompatible: true);
|
Incompatible: true);
|
||||||
|
|
||||||
private int SeedOf(int schoolId)
|
private int SeedOf(int schoolId)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
|
/// <summary>Who may delete or manage a school. Ownerless saves can be deleted by anyone logged in.</summary>
|
||||||
|
internal static class SchoolOwnership
|
||||||
|
{
|
||||||
|
public static bool IsOwnerless(SchoolState school) => string.IsNullOrWhiteSpace(school.Owner);
|
||||||
|
|
||||||
|
public static bool IsOwner(SchoolState school, string normalizedUserName)
|
||||||
|
{
|
||||||
|
if (IsOwnerless(school) || string.IsNullOrWhiteSpace(normalizedUserName))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(school.Owner, normalizedUserName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanDelete(SchoolState school, string normalizedUserName) =>
|
||||||
|
school.Incompatible || IsOwnerless(school) || IsOwner(school, normalizedUserName);
|
||||||
|
|
||||||
|
public static bool CanManage(SchoolState school, string normalizedUserName) =>
|
||||||
|
IsOwner(school, normalizedUserName);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ internal sealed record SchoolState(
|
|||||||
byte SpeedIndex,
|
byte SpeedIndex,
|
||||||
IReadOnlyList<string> ModIds,
|
IReadOnlyList<string> ModIds,
|
||||||
int Seed,
|
int Seed,
|
||||||
|
string? Owner = null,
|
||||||
bool Incompatible = false);
|
bool Incompatible = false);
|
||||||
|
|
||||||
/// <summary>Everything the main menu needs in one read.</summary>
|
/// <summary>Everything the main menu needs in one read.</summary>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ internal sealed class SchoolSave
|
|||||||
|
|
||||||
public SchoolDressRules? DressRules { get; init; }
|
public SchoolDressRules? DressRules { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Normalized player name. Missing or blank means ownerless.</summary>
|
||||||
|
public string? Owner { get; init; }
|
||||||
|
|
||||||
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
|
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
|
||||||
public SwarmUiConfigFile? PortraitSettings { get; init; }
|
public SwarmUiConfigFile? PortraitSettings { get; init; }
|
||||||
}
|
}
|
||||||
@@ -175,6 +178,7 @@ internal sealed class SchoolStore
|
|||||||
NativeLanguage = save.NativeLanguage,
|
NativeLanguage = save.NativeLanguage,
|
||||||
Presence = save.Presence,
|
Presence = save.Presence,
|
||||||
DressRules = save.DressRules,
|
DressRules = save.DressRules,
|
||||||
|
Owner = save.Owner,
|
||||||
PortraitSettings = save.PortraitSettings,
|
PortraitSettings = save.PortraitSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly int? _createSeed;
|
private readonly int? _createSeed;
|
||||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||||
private readonly SchoolDressRules? _savedDressRules;
|
private readonly SchoolDressRules? _savedDressRules;
|
||||||
|
private readonly string? _owner;
|
||||||
private readonly SwarmUiConfigFile? _portraitSettings;
|
private readonly SwarmUiConfigFile? _portraitSettings;
|
||||||
private readonly Action<int> _onFailed;
|
private readonly Action<int> _onFailed;
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ internal sealed class SchoolWorker
|
|||||||
int? createSeed,
|
int? createSeed,
|
||||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||||
SchoolDressRules? savedDressRules,
|
SchoolDressRules? savedDressRules,
|
||||||
|
string? owner,
|
||||||
SwarmUiConfigFile? portraitSettings,
|
SwarmUiConfigFile? portraitSettings,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
@@ -98,6 +100,7 @@ internal sealed class SchoolWorker
|
|||||||
_createSeed = createSeed;
|
_createSeed = createSeed;
|
||||||
_savedPresence = savedPresence;
|
_savedPresence = savedPresence;
|
||||||
_savedDressRules = savedDressRules;
|
_savedDressRules = savedDressRules;
|
||||||
|
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
|
||||||
_portraitSettings = portraitSettings;
|
_portraitSettings = portraitSettings;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clients = clients;
|
_clients = clients;
|
||||||
@@ -106,7 +109,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, modIds ?? [], createSeed ?? 0);
|
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, modIds ?? [], createSeed ?? 0, _owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Id => _id;
|
public int Id => _id;
|
||||||
@@ -655,7 +658,8 @@ internal sealed class SchoolWorker
|
|||||||
school.Clock.IsRunning,
|
school.Clock.IsRunning,
|
||||||
(byte)school.Clock.SpeedIndex,
|
(byte)school.Clock.SpeedIndex,
|
||||||
school.Catalog?.PackIds ?? _modIds ?? [],
|
school.Catalog?.PackIds ?? _modIds ?? [],
|
||||||
school.PeopleSeed));
|
school.PeopleSeed,
|
||||||
|
_owner));
|
||||||
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);
|
||||||
@@ -978,8 +982,9 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
if (DressGenerator.NeedsDressing(roster, applicants))
|
if (DressGenerator.NeedsDressing(roster, applicants))
|
||||||
{
|
{
|
||||||
throw new SchoolContentUnavailableException(
|
roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time);
|
||||||
$"School {_id} people have no clothes; the school was left unstarted.");
|
applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time);
|
||||||
|
generated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
RequireKnownApparel(catalog, roster, applicants);
|
RequireKnownApparel(catalog, roster, applicants);
|
||||||
@@ -1076,6 +1081,7 @@ internal sealed class SchoolWorker
|
|||||||
NativeLanguage = _nativeLanguage,
|
NativeLanguage = _nativeLanguage,
|
||||||
Presence = school.CapturePresence(),
|
Presence = school.CapturePresence(),
|
||||||
DressRules = school.DressRules,
|
DressRules = school.DressRules,
|
||||||
|
Owner = _owner,
|
||||||
PortraitSettings = _portraitSettings,
|
PortraitSettings = _portraitSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Buffers;
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using HSchool.Protocol;
|
using HSchool.Protocol;
|
||||||
using HSchool.Server.Game;
|
using HSchool.Server.Game;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
namespace HSchool.Server.Net;
|
namespace HSchool.Server.Net;
|
||||||
|
|
||||||
@@ -146,16 +147,28 @@ internal sealed class GameSocketHandler(
|
|||||||
|
|
||||||
case MessageType.ClientSetRunning:
|
case MessageType.ClientSetRunning:
|
||||||
var setRunning = ProtocolCodec.ReadSetRunning(frame);
|
var setRunning = ProtocolCodec.ReadSetRunning(frame);
|
||||||
commands.Enqueue(new GameCommand.SetRunning(client.PlayerId, setRunning.Running));
|
if (TryNormalizedUser(client, out var runningUser))
|
||||||
|
{
|
||||||
|
commands.Enqueue(new GameCommand.SetRunning(client.PlayerId, setRunning.Running, runningUser));
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MessageType.ClientSetSpeed:
|
case MessageType.ClientSetSpeed:
|
||||||
var setSpeed = ProtocolCodec.ReadSetSpeed(frame);
|
var setSpeed = ProtocolCodec.ReadSetSpeed(frame);
|
||||||
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
|
if (TryNormalizedUser(client, out var speedUser))
|
||||||
|
{
|
||||||
|
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex, speedUser));
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MessageType.ClientSkipEmpty:
|
case MessageType.ClientSkipEmpty:
|
||||||
commands.Enqueue(new GameCommand.SkipEmpty(client.PlayerId));
|
if (TryNormalizedUser(client, out var skipUser))
|
||||||
|
{
|
||||||
|
commands.Enqueue(new GameCommand.SkipEmpty(client.PlayerId, skipUser));
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -227,6 +240,9 @@ internal sealed class GameSocketHandler(
|
|||||||
client.TrySend(frame.AsMemory(0, length));
|
client.TrySend(frame.AsMemory(0, length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizedUser(GameClient client, out string normalized) =>
|
||||||
|
SchoolNames.TryNormalize(client.UserName, out normalized);
|
||||||
|
|
||||||
private static async Task CloseAsync(
|
private static async Task CloseAsync(
|
||||||
WebSocket socket,
|
WebSocket socket,
|
||||||
WebSocketCloseStatus status,
|
WebSocketCloseStatus status,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ builder.Services
|
|||||||
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
|
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
|
||||||
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
|
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
|
||||||
.Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.")
|
.Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.")
|
||||||
|
.Validate(options => options.MaxSchoolsTotal is > 0 and <= 255, "Simulation:MaxSchoolsTotal must be between 1 and 255.")
|
||||||
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
|
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
|
||||||
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
|
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
|
||||||
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
|
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
},
|
},
|
||||||
"Simulation": {
|
"Simulation": {
|
||||||
"TickRate": 20,
|
"TickRate": 20,
|
||||||
"MaxSchools": 6,
|
"MaxSchools": 2,
|
||||||
|
"MaxSchoolsTotal": 16,
|
||||||
"GameMinutesPerRealSecond": 1,
|
"GameMinutesPerRealSecond": 1,
|
||||||
"DefaultStartDate": "2012-03-31T06:00:00",
|
"DefaultStartDate": "2012-03-31T06:00:00",
|
||||||
"SavesDirectory": "saves",
|
"SavesDirectory": "saves",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public enum SchoolCreationError
|
|||||||
{
|
{
|
||||||
None = 0,
|
None = 0,
|
||||||
LimitReached,
|
LimitReached,
|
||||||
|
ServerFull,
|
||||||
InvalidName,
|
InvalidName,
|
||||||
InvalidStartDate,
|
InvalidStartDate,
|
||||||
InvalidMap,
|
InvalidMap,
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ public sealed class SimulationOptions
|
|||||||
/// <summary>Fixed simulation steps per second.</summary>
|
/// <summary>Fixed simulation steps per second.</summary>
|
||||||
public int TickRate { get; set; } = 20;
|
public int TickRate { get; set; } = 20;
|
||||||
|
|
||||||
/// <summary>How many schools may exist at the same time.</summary>
|
/// <summary>How many schools one player may own at the same time.</summary>
|
||||||
public int MaxSchools { get; set; } = 6;
|
public int MaxSchools { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>How many school workers the process may run at once.</summary>
|
||||||
|
public int MaxSchoolsTotal { get; set; } = 16;
|
||||||
|
|
||||||
/// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary>
|
/// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary>
|
||||||
public double GameMinutesPerRealSecond { get; set; } = 1d;
|
public double GameMinutesPerRealSecond { get; set; } = 1d;
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
|
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
|
||||||
Assert.Equal(20, welcome.TickRate);
|
Assert.Equal(20, welcome.TickRate);
|
||||||
Assert.Equal(6, welcome.MaxSchools);
|
Assert.Equal(2, welcome.MaxSchools);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChangingSpeed_DoesNotResumeAPausedSchool()
|
public async Task ChangingSpeed_DoesNotResumeAPausedSchool()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
|
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
@@ -52,7 +52,7 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task NoSchoolOpen_MeansNoClockFramesButTheCalendarKeepsRunning()
|
public async Task NoSchoolOpen_MeansNoClockFramesButTheCalendarKeepsRunning()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Живёт сама", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Живёт сама", StartDate);
|
||||||
|
|
||||||
@@ -72,11 +72,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpeningASchool_StreamsItsClock()
|
public async Task OpeningASchool_StreamsItsClock()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
var first = await ReceiveClockAsync(socket);
|
var first = await ReceiveClockAsync(socket);
|
||||||
|
|
||||||
Assert.Equal(school.Id, first.SchoolId);
|
Assert.Equal(school.Id, first.SchoolId);
|
||||||
@@ -93,11 +93,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpeningASchool_SendsAMapSnapshot()
|
public async Task OpeningASchool_SendsAMapSnapshot()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Снимок карты", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Снимок карты", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||||
|
|
||||||
Assert.Equal(school.Id, snapshot.SchoolId);
|
Assert.Equal(school.Id, snapshot.SchoolId);
|
||||||
@@ -116,11 +116,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpeningASchool_LabelsTheSnapshotInTheHelloLocale()
|
public async Task OpeningASchool_LabelsTheSnapshotInTheHelloLocale()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id, ProtocolConstants.LocaleEnglish);
|
using var socket = await OpenSchoolAsync(school.Id, client, ProtocolConstants.LocaleEnglish);
|
||||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||||
|
|
||||||
Assert.Equal("Principal's office", Assert.Single(snapshot.Nodes, node => node.Id == "principals-office").Name);
|
Assert.Equal("Principal's office", Assert.Single(snapshot.Nodes, node => node.Id == "principals-office").Name);
|
||||||
@@ -129,11 +129,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpeningASchool_WithACustomMap_ReturnsThatLayout()
|
public async Task OpeningASchool_WithACustomMap_ReturnsThatLayout()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap);
|
var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||||
|
|
||||||
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
|
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
|
||||||
@@ -144,14 +144,14 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReloadFromDisk_RestoresACustomMap()
|
public async Task ReloadFromDisk_RestoresACustomMap()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateWithMapAsync(client, "Карта с диска", StartDate, SchoolApiTests.SimpleCustomMap);
|
var school = await SchoolApiTests.CreateWithMapAsync(client, "Карта с диска", StartDate, SchoolApiTests.SimpleCustomMap);
|
||||||
|
|
||||||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
reload.EnsureSuccessStatusCode();
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||||
|
|
||||||
Assert.Contains(snapshot.Nodes, node => node.Id == "office");
|
Assert.Contains(snapshot.Nodes, node => node.Id == "office");
|
||||||
@@ -161,12 +161,12 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnPresenceNotTheSnapshot()
|
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnPresenceNotTheSnapshot()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start);
|
var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||||
Assert.Contains(snapshot.Nodes, node => node.Id == "classroom-101");
|
Assert.Contains(snapshot.Nodes, node => node.Id == "classroom-101");
|
||||||
|
|
||||||
@@ -224,12 +224,12 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SkipEmpty_DuringWorkHours_IsIgnored()
|
public async Task SkipEmpty_DuringWorkHours_IsIgnored()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Пропуск в учебное время", start);
|
var school = await SchoolApiTests.CreateAsync(client, "Пропуск в учебное время", start);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||||||
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
|
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
|
||||||
@@ -247,11 +247,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Pausing_FreezesTheClock()
|
public async Task Pausing_FreezesTheClock()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Пауза", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Пауза", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
|
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
@@ -267,11 +267,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SpeedIndex_ChangesHowFastTheCalendarMoves()
|
public async Task SpeedIndex_ChangesHowFastTheCalendarMoves()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Быстрая", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Быстрая", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
|
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
@@ -288,11 +288,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task LeavingASchool_KeepsItsClockRunning()
|
public async Task LeavingASchool_KeepsItsClockRunning()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
|
||||||
|
|
||||||
using (var socket = await OpenSchoolAsync(school.Id))
|
using (var socket = await OpenSchoolAsync(school.Id, client))
|
||||||
{
|
{
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
|
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
|
||||||
@@ -309,11 +309,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task APausedSchool_StaysPausedAfterLeaving()
|
public async Task APausedSchool_StaysPausedAfterLeaving()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
|
||||||
|
|
||||||
using (var socket = await OpenSchoolAsync(school.Id))
|
using (var socket = await OpenSchoolAsync(school.Id, client))
|
||||||
{
|
{
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
@@ -333,11 +333,11 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeletingTheOpenSchool_TellsTheClient()
|
public async Task DeletingTheOpenSchool_TellsTheClient()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
using var socket = await OpenSchoolAsync(school.Id, client);
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
|
|
||||||
using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||||||
@@ -365,12 +365,12 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PausingOneSchool_DoesNotStopAnother()
|
public async Task PausingOneSchool_DoesNotStopAnother()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var paused = await SchoolApiTests.CreateAsync(client, "На паузе", StartDate);
|
var paused = await SchoolApiTests.CreateAsync(client, "На паузе", StartDate);
|
||||||
var running = await SchoolApiTests.CreateAsync(client, "Идёт дальше", StartDate);
|
var running = await SchoolApiTests.CreateAsync(client, "Идёт дальше", StartDate);
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(paused.Id);
|
using var socket = await OpenSchoolAsync(paused.Id, client);
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||||||
@@ -387,14 +387,47 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
Assert.True(runningLater.GameTime > running.GameTime, "Pausing one school stopped the other.");
|
Assert.True(runningLater.GameTime > running.GameTime, "Pausing one school stopped the other.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guest_SetRunning_DoesNotChangeOwnerRunning()
|
||||||
|
{
|
||||||
|
using var ownerClient = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "GuestClockOwner");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(ownerClient);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(ownerClient, "Гостевые часы", StartDate);
|
||||||
|
|
||||||
|
var guestCookie = await SchoolApiTests.LoginAndGetCookieAsync(
|
||||||
|
fixture.App.CreateHttpClient("server"),
|
||||||
|
"GuestClockViewer");
|
||||||
|
|
||||||
|
using var socket = new ClientWebSocket();
|
||||||
|
socket.Options.SetRequestHeader("Cookie", guestCookie);
|
||||||
|
var http = fixture.App.GetEndpoint("server", "http");
|
||||||
|
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
|
||||||
|
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleRussian)));
|
||||||
|
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id)));
|
||||||
|
_ = await ReceiveClockAsync(socket);
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var ownerView = await FindAsync(ownerClient, school.Id);
|
||||||
|
Assert.True(ownerView.Running);
|
||||||
|
|
||||||
|
using var delete = await ownerClient.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||||||
|
delete.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReloadFromDisk_RestoresAPausedClock()
|
public async Task ReloadFromDisk_RestoresAPausedClock()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = await CreateOwnerHttpClientAsync();
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Снимок паузы", StartDate);
|
var school = await SchoolApiTests.CreateAsync(client, "Снимок паузы", StartDate);
|
||||||
|
|
||||||
using (var socket = await OpenSchoolAsync(school.Id))
|
using (var socket = await OpenSchoolAsync(school.Id, client))
|
||||||
{
|
{
|
||||||
await ReceiveClockAsync(socket);
|
await ReceiveClockAsync(socket);
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
@@ -479,21 +512,57 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
{
|
{
|
||||||
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||||||
var school = state.Schools.SingleOrDefault(candidate => candidate.Id == schoolId);
|
var school = state.Schools.SingleOrDefault(candidate => candidate.Id == schoolId);
|
||||||
|
if (school is not null)
|
||||||
Assert.NotNull(school);
|
{
|
||||||
return school;
|
return school;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian)
|
var other = state.Others.SingleOrDefault(candidate => candidate.Id == schoolId);
|
||||||
|
Assert.NotNull(other);
|
||||||
|
return new SchoolApiTests.SchoolResponse(
|
||||||
|
other.Id,
|
||||||
|
other.Name,
|
||||||
|
other.GameTime,
|
||||||
|
other.Running,
|
||||||
|
other.SpeedIndex,
|
||||||
|
other.Seed,
|
||||||
|
Mine: false,
|
||||||
|
other.ModIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpClient> CreateOwnerHttpClientAsync()
|
||||||
{
|
{
|
||||||
using var httpClient = fixture.App.CreateHttpClient("server");
|
using var template = fixture.App.CreateHttpClient("server");
|
||||||
var socket = await ConnectAsync(httpClient, locale);
|
return await SchoolApiTests.CreateIsolatedClientAsync(template, SchoolApiTests.TestUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClientWebSocket> OpenSchoolAsync(
|
||||||
|
int schoolId,
|
||||||
|
HttpClient ownerClient,
|
||||||
|
byte locale = ProtocolConstants.LocaleRussian)
|
||||||
|
{
|
||||||
|
var cookie = ownerClient.DefaultRequestHeaders.TryGetValues("Cookie", out var values)
|
||||||
|
? values.First()
|
||||||
|
: await SchoolApiTests.WebSocketCookieAsync(ownerClient);
|
||||||
|
var socket = await ConnectWithCookieAsync(cookie, locale);
|
||||||
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
|
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
|
||||||
await SendAsync(socket, buffer =>
|
await SendAsync(socket, buffer =>
|
||||||
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
|
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ClientWebSocket> ConnectWithCookieAsync(string cookie, byte locale = ProtocolConstants.LocaleRussian)
|
||||||
|
{
|
||||||
|
var socket = new ClientWebSocket();
|
||||||
|
socket.Options.SetRequestHeader("Cookie", cookie);
|
||||||
|
var http = fixture.App.GetEndpoint("server", "http");
|
||||||
|
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
|
||||||
|
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
|
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
|
||||||
{
|
{
|
||||||
using var httpClient = fixture.App.CreateHttpClient("server");
|
using var httpClient = fixture.App.CreateHttpClient("server");
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace HSchool.AppHost.Tests;
|
|
||||||
|
|
||||||
[Collection(AppHostCollection.Name)]
|
|
||||||
public class GoldenSaveTests(AppHostFixture fixture)
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task CurrentFormatSave_LoadsTheSamePeopleAndTime()
|
|
||||||
{
|
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
|
||||||
await SchoolApiTests.ResetAsync(client);
|
|
||||||
await InstallAsync(client, "current");
|
|
||||||
|
|
||||||
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
|
||||||
var school = Assert.Single(state.Schools);
|
|
||||||
Assert.Equal(1, school.Id);
|
|
||||||
Assert.Equal(1, school.Seed);
|
|
||||||
Assert.Equal("Золотая", school.Name);
|
|
||||||
Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime);
|
|
||||||
Assert.False(school.Running);
|
|
||||||
|
|
||||||
var people = await PeopleAsync(client, school.Id);
|
|
||||||
Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray());
|
|
||||||
Assert.Contains(people, person => person.Roles.Contains("student"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SaveWithoutNativeLanguage_LoadsWithoutReshuffling()
|
|
||||||
{
|
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
|
||||||
await SchoolApiTests.ResetAsync(client);
|
|
||||||
await InstallAsync(client, "legacy-no-native");
|
|
||||||
|
|
||||||
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
|
||||||
var school = Assert.Single(state.Schools);
|
|
||||||
Assert.Equal(1, school.Id);
|
|
||||||
Assert.Equal(1, school.Seed);
|
|
||||||
Assert.Equal("Золотая", school.Name);
|
|
||||||
|
|
||||||
var people = await PeopleAsync(client, school.Id);
|
|
||||||
Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task InstallAsync(HttpClient client, string folder)
|
|
||||||
{
|
|
||||||
var directory = await SavesDirectoryAsync(client);
|
|
||||||
var source = Path.Combine(AppContext.BaseDirectory, "golden", folder);
|
|
||||||
foreach (var file in Directory.EnumerateFiles(source))
|
|
||||||
{
|
|
||||||
File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
|
||||||
reload.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> SavesDirectoryAsync(HttpClient client)
|
|
||||||
{
|
|
||||||
var payload = await client.GetFromJsonAsync<SavesDirectoryResponse>(
|
|
||||||
"/api/dev/saves-directory",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(payload);
|
|
||||||
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
|
|
||||||
return payload.Path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<PersonRow>> PeopleAsync(HttpClient client, int schoolId)
|
|
||||||
{
|
|
||||||
var page = await client.GetFromJsonAsync<PeoplePage>(
|
|
||||||
$"/api/schools/{schoolId}/people?pageSize=100",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(page);
|
|
||||||
return page.People
|
|
||||||
.OrderBy(row => row.Id, StringComparer.Ordinal)
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string[] ExpectedNames()
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "golden", "current", "1.people.json"));
|
|
||||||
using var document = JsonDocument.Parse(json);
|
|
||||||
return document.RootElement.GetProperty("people")
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(person =>
|
|
||||||
{
|
|
||||||
var name = person.GetProperty("name");
|
|
||||||
var surname = name.GetProperty("surname").GetString() ?? "";
|
|
||||||
var given = name.GetProperty("given").GetString() ?? "";
|
|
||||||
var patronymic = name.GetProperty("patronymic").GetString() ?? "";
|
|
||||||
return string.Join(' ', new[] { surname, given, patronymic }.Where(part => part.Length > 0));
|
|
||||||
})
|
|
||||||
.OrderBy(full => full, StringComparer.Ordinal)
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record SavesDirectoryResponse(string Path);
|
|
||||||
|
|
||||||
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
|
||||||
|
|
||||||
private sealed record PersonRow(string Id, string FullName, IReadOnlyList<string> Roles);
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,8 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
var state = await GetSchoolsAsync(client);
|
var state = await GetSchoolsAsync(client);
|
||||||
|
|
||||||
Assert.Equal(6, state.MaxSchools);
|
Assert.Equal(2, state.MaxSchools);
|
||||||
|
Assert.Equal(7, state.MaxSchoolsTotal);
|
||||||
Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate);
|
Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate);
|
||||||
|
|
||||||
// Without the "Z" the browser would read the start date in its own time zone and the
|
// Without the "Z" the browser would read the start date in its own time zone and the
|
||||||
@@ -493,7 +494,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
Assert.NotNull(status);
|
Assert.NotNull(status);
|
||||||
Assert.Equal(20, status.TickRate);
|
Assert.Equal(20, status.TickRate);
|
||||||
Assert.Equal(6, status.MaxSchools);
|
Assert.Equal(2, status.MaxSchools);
|
||||||
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
|
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +527,63 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
_ = await LoginAndGetCookieAsync(client, userName);
|
_ = await LoginAndGetCookieAsync(client, userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static async Task<HttpClient> CreateIsolatedClientAsync(HttpClient template, string userName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(template.BaseAddress);
|
||||||
|
|
||||||
|
var login = new HttpClient { BaseAddress = template.BaseAddress };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cookie = await LoginAndGetCookieAsync(login, userName);
|
||||||
|
var handler = new HttpClientHandler { UseCookies = false };
|
||||||
|
var client = new HttpClient(handler) { BaseAddress = template.BaseAddress };
|
||||||
|
client.DefaultRequestHeaders.Add("Cookie", cookie);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
login.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<HttpClient> CreateIsolatedClientAsync(DistributedApplication app, string userName)
|
||||||
|
{
|
||||||
|
var login = app.CreateHttpClient("server");
|
||||||
|
var cookie = await LoginAndGetCookieAsync(login, userName);
|
||||||
|
login.Dispose();
|
||||||
|
|
||||||
|
var handler = new HttpClientHandler { UseCookies = false };
|
||||||
|
var client = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(app.GetEndpoint("server", "http").ToString()),
|
||||||
|
};
|
||||||
|
client.DefaultRequestHeaders.Add("Cookie", cookie);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<string> WebSocketCookieAsync(HttpClient client, string userName = TestUserName)
|
||||||
|
{
|
||||||
|
if (client.DefaultRequestHeaders.TryGetValues("Cookie", out var values))
|
||||||
|
{
|
||||||
|
var cookie = values.First();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cookie))
|
||||||
|
{
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ArgumentNullException.ThrowIfNull(client.BaseAddress);
|
||||||
|
var login = new HttpClient { BaseAddress = client.BaseAddress };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await LoginAndGetCookieAsync(login, userName);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
login.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static async Task<string> LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName)
|
internal static async Task<string> LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName)
|
||||||
{
|
{
|
||||||
using var first = await client.PostAsJsonAsync(
|
using var first = await client.PostAsJsonAsync(
|
||||||
@@ -575,16 +633,61 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static async Task ResetAsync(HttpClient client)
|
internal static async Task ResetAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
await WipeAllSavesAsync(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task WipeAllSavesAsync(HttpClient client)
|
||||||
{
|
{
|
||||||
await LoginAsync(client);
|
await LoginAsync(client);
|
||||||
|
|
||||||
|
for (var pass = 0; pass < 8; pass++)
|
||||||
|
{
|
||||||
var state = await GetSchoolsAsync(client);
|
var state = await GetSchoolsAsync(client);
|
||||||
|
if (state.Schools.Count == 0 && state.Others.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var school in state.Schools)
|
foreach (var school in state.Schools)
|
||||||
{
|
{
|
||||||
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var other in state.Others)
|
||||||
|
{
|
||||||
|
HttpClient? ownedClient = null;
|
||||||
|
var deleter = client;
|
||||||
|
if (!string.IsNullOrWhiteSpace(other.Owner))
|
||||||
|
{
|
||||||
|
ownedClient = await CreateIsolatedClientAsync(client, other.Owner);
|
||||||
|
deleter = ownedClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await deleter.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ownedClient?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = await GetSchoolsAsync(client);
|
||||||
|
if (left.Schools.Count > 0 || left.Others.Count > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Could not wipe saves: {left.Schools.Count} own and {left.Others.Count} other schools remain.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static async Task<SchoolsResponse> GetSchoolsAsync(HttpClient client)
|
internal static async Task<SchoolsResponse> GetSchoolsAsync(HttpClient client)
|
||||||
@@ -649,7 +752,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
return save;
|
return save;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> SavesDirectoryAsync(HttpClient client)
|
internal static async Task<string> SavesDirectoryAsync(HttpClient client)
|
||||||
{
|
{
|
||||||
var payload = await client.GetFromJsonAsync<PathResponse>(
|
var payload = await client.GetFromJsonAsync<PathResponse>(
|
||||||
"/api/dev/saves-directory",
|
"/api/dev/saves-directory",
|
||||||
@@ -663,6 +766,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
string? CountryId,
|
string? CountryId,
|
||||||
string? ClimatePresetId,
|
string? ClimatePresetId,
|
||||||
string? NativeLanguage,
|
string? NativeLanguage,
|
||||||
|
string? Owner,
|
||||||
SwarmUiSaveFile? PortraitSettings);
|
SwarmUiSaveFile? PortraitSettings);
|
||||||
|
|
||||||
private sealed record SwarmUiSaveFile(string? ActivePresetId, IReadOnlyList<SwarmUiPresetSave>? Presets);
|
private sealed record SwarmUiSaveFile(string? ActivePresetId, IReadOnlyList<SwarmUiPresetSave>? Presets);
|
||||||
@@ -681,7 +785,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
links = new[] { new { a = "yard", b = "office" } },
|
links = new[] { new { a = "yard", b = "office" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Task<HttpResponseMessage> PostAsync(
|
internal static Task<HttpResponseMessage> PostAsync(
|
||||||
HttpClient client,
|
HttpClient client,
|
||||||
string name,
|
string name,
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
@@ -704,14 +808,27 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
bool Running,
|
bool Running,
|
||||||
byte SpeedIndex,
|
byte SpeedIndex,
|
||||||
int Seed,
|
int Seed,
|
||||||
|
bool Mine,
|
||||||
|
IReadOnlyList<string>? ModIds = null);
|
||||||
|
|
||||||
|
internal sealed record OtherSchoolResponse(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
DateTime GameTime,
|
||||||
|
bool Running,
|
||||||
|
byte SpeedIndex,
|
||||||
|
int Seed,
|
||||||
|
string? Owner,
|
||||||
IReadOnlyList<string>? ModIds = null);
|
IReadOnlyList<string>? ModIds = null);
|
||||||
|
|
||||||
internal sealed record SchoolsResponse(
|
internal sealed record SchoolsResponse(
|
||||||
int MaxSchools,
|
int MaxSchools,
|
||||||
|
int MaxSchoolsTotal,
|
||||||
DateTime DefaultStartDate,
|
DateTime DefaultStartDate,
|
||||||
double GameMinutesPerRealSecond,
|
double GameMinutesPerRealSecond,
|
||||||
int SchoolWeekDays,
|
int SchoolWeekDays,
|
||||||
IReadOnlyList<SchoolResponse> Schools);
|
IReadOnlyList<SchoolResponse> Schools,
|
||||||
|
IReadOnlyList<OtherSchoolResponse> Others);
|
||||||
|
|
||||||
private sealed record RandomNameResponse(string Name);
|
private sealed record RandomNameResponse(string Name);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
[Collection(AppHostCollection.Name)]
|
||||||
|
public class SchoolOwnerApiTests(AppHostFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TwoUsers_EachGetsTwoSchools_ThirdOwnIsLimitReached_OthersListForeignSchools()
|
||||||
|
{
|
||||||
|
using var wipe = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(wipe);
|
||||||
|
|
||||||
|
var alice = await CreateUserAsync("OwnerAlice");
|
||||||
|
var bob = await CreateUserAsync("OwnerBob");
|
||||||
|
await ResetUserAsync(alice);
|
||||||
|
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
await SchoolApiTests.CreateAsync(alice, $"Alice {i + 1}", Start);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
await SchoolApiTests.CreateAsync(bob, $"Bob {i + 1}", Start);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var third = await SchoolApiTests.PostAsync(alice, "Alice extra", Start);
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, third.StatusCode);
|
||||||
|
Assert.Equal("school-limit-reached", await SchoolApiTests.ProblemCodeAsync(third));
|
||||||
|
|
||||||
|
var aliceList = await SchoolApiTests.GetSchoolsAsync(alice);
|
||||||
|
Assert.Equal(2, aliceList.Schools.Count);
|
||||||
|
Assert.All(aliceList.Schools, school => Assert.True(school.Mine));
|
||||||
|
Assert.Equal(2, aliceList.Others.Count);
|
||||||
|
Assert.All(aliceList.Others, other => Assert.Equal("OwnerBob", other.Owner));
|
||||||
|
Assert.DoesNotContain(aliceList.Schools, school => school.Name.StartsWith("Bob", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
foreach (var client in new[] { alice, bob })
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cleanup = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EighthSchool_OnFullServer_ReturnsServerFull()
|
||||||
|
{
|
||||||
|
using var wipe = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(wipe);
|
||||||
|
|
||||||
|
var users = new[] { "SrvA", "SrvB", "SrvC", "SrvD" };
|
||||||
|
var clients = new List<HttpClient>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var u = 0; u < users.Length; u++)
|
||||||
|
{
|
||||||
|
var client = await CreateUserAsync(users[u]);
|
||||||
|
clients.Add(client);
|
||||||
|
var count = u < 3 ? 2 : 1;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
await SchoolApiTests.CreateAsync(client, $"{users[u]}-{i}", Start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var spare = await CreateUserAsync("SrvSpare");
|
||||||
|
using var response = await SchoolApiTests.PostAsync(spare, "One too many", Start);
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||||
|
Assert.Equal("server-full", await SchoolApiTests.ProblemCodeAsync(response));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var client in clients)
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cleanup = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(cleanup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guest_Hire_IsForbidden()
|
||||||
|
{
|
||||||
|
using var wipe = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(wipe);
|
||||||
|
|
||||||
|
var owner = await CreateUserAsync("HireOwner");
|
||||||
|
var guest = await CreateUserAsync("HireGuest");
|
||||||
|
await ResetUserAsync(owner);
|
||||||
|
|
||||||
|
var school = await SchoolApiTests.CreateAsync(owner, "Чужой найм", Start);
|
||||||
|
|
||||||
|
using var staffing = await guest.GetAsync($"/api/schools/{school.Id}/staffing", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, staffing.StatusCode);
|
||||||
|
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(staffing));
|
||||||
|
|
||||||
|
using var hire = await guest.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/staff/hire",
|
||||||
|
new { personId = "a0.p0", position = "Teacher" },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, hire.StatusCode);
|
||||||
|
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(hire));
|
||||||
|
|
||||||
|
owner.Dispose();
|
||||||
|
guest.Dispose();
|
||||||
|
|
||||||
|
using var cleanup = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OwnerlessSave_AppearsInOthers_AndCanBeDeletedByAnotherUser()
|
||||||
|
{
|
||||||
|
using var wipe = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(wipe);
|
||||||
|
|
||||||
|
var first = await CreateUserAsync("SaveAlpha");
|
||||||
|
var created = await SchoolApiTests.CreateAsync(first, "Бесхозная", Start);
|
||||||
|
|
||||||
|
var directory = await SavesDirectoryAsync(first);
|
||||||
|
var saveFiles = new[]
|
||||||
|
{
|
||||||
|
$"{created.Id}.json",
|
||||||
|
$"{created.Id}.people.json",
|
||||||
|
$"{created.Id}.timetable.json",
|
||||||
|
};
|
||||||
|
var copies = saveFiles
|
||||||
|
.Select(name => Path.Combine(directory, name))
|
||||||
|
.Where(File.Exists)
|
||||||
|
.ToDictionary(path => path, File.ReadAllBytes);
|
||||||
|
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(first);
|
||||||
|
|
||||||
|
foreach (var (copiedPath, bytes) in copies)
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(copiedPath, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
var schoolSavePath = Path.Combine(directory, $"{created.Id}.json");
|
||||||
|
var node = JsonNode.Parse(File.ReadAllText(schoolSavePath))
|
||||||
|
?? throw new InvalidOperationException("School save parsed to nothing.");
|
||||||
|
node.AsObject().Remove("owner");
|
||||||
|
File.WriteAllText(
|
||||||
|
schoolSavePath,
|
||||||
|
node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|
||||||
|
using var reload = await first.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var firstList = await SchoolApiTests.GetSchoolsAsync(first);
|
||||||
|
Assert.Empty(firstList.Schools);
|
||||||
|
var orphan = Assert.Single(firstList.Others, other => other.Name == "Бесхозная");
|
||||||
|
Assert.Null(orphan.Owner);
|
||||||
|
|
||||||
|
var second = await CreateUserAsync("SaveBeta");
|
||||||
|
using var delete = await second.DeleteAsync($"/api/schools/{orphan.Id}", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
|
||||||
|
|
||||||
|
var after = await SchoolApiTests.GetSchoolsAsync(second);
|
||||||
|
Assert.DoesNotContain(after.Others, other => other.Id == orphan.Id);
|
||||||
|
|
||||||
|
first.Dispose();
|
||||||
|
second.Dispose();
|
||||||
|
|
||||||
|
using var cleanup = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpClient> CreateUserAsync(string userName) =>
|
||||||
|
await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, userName);
|
||||||
|
|
||||||
|
private static async Task ResetUserAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
await SchoolApiTests.LoginAsync(client);
|
||||||
|
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||||||
|
foreach (var school in state.Schools)
|
||||||
|
{
|
||||||
|
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var other in state.Others)
|
||||||
|
{
|
||||||
|
using var response = await client.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetAllAsync()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.WipeAllSavesAsync(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> SavesDirectoryAsync(HttpClient client) =>
|
||||||
|
await SchoolApiTests.SavesDirectoryAsync(client);
|
||||||
|
}
|
||||||
@@ -19,14 +19,21 @@ public class SchoolSeedTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
var first = await SchoolApiTests.CreateAsync(client, "Сид одинаковый А", Start, seed: 42);
|
var first = await SchoolApiTests.CreateAsync(client, "Сид одинаковый А", Start, seed: 42);
|
||||||
var second = 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 firstYearFive = await YearFiveAsync(client, first.Id);
|
||||||
var secondYearFive = await YearFiveAsync(client, second.Id);
|
var secondYearFive = await YearFiveAsync(client, second.Id);
|
||||||
var otherYearFive = await YearFiveAsync(client, other.Id);
|
|
||||||
|
|
||||||
Assert.Equal(firstYearFive, secondYearFive);
|
Assert.Equal(firstYearFive, secondYearFive);
|
||||||
Assert.NotEqual(firstYearFive, otherYearFive);
|
|
||||||
|
using (var delete = await client.DeleteAsync($"/api/schools/{first.Id}", TestContext.Current.CancellationToken))
|
||||||
|
{
|
||||||
|
delete.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
var other = await SchoolApiTests.CreateAsync(client, "Сид другой", Start, seed: 7);
|
||||||
|
var otherYearFive = await YearFiveAsync(client, other.Id);
|
||||||
|
|
||||||
|
Assert.NotEqual(secondYearFive, otherYearFive);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ public class ServiceabilityTests(AppHostFixture fixture)
|
|||||||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
reload.EnsureSuccessStatusCode();
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
|
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||||||
|
Assert.Empty(state.Schools);
|
||||||
|
var broken = Assert.Single(state.Others);
|
||||||
|
Assert.Equal(1, broken.Id);
|
||||||
|
|
||||||
|
using var people = await client.GetAsync(
|
||||||
|
$"/api/schools/{broken.Id}/people?pageSize=1",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, people.StatusCode);
|
||||||
|
|
||||||
Assert.Equal(before, File.ReadAllBytes(path));
|
Assert.Equal(before, File.ReadAllBytes(path));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -54,7 +63,16 @@ public class ServiceabilityTests(AppHostFixture fixture)
|
|||||||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
reload.EnsureSuccessStatusCode();
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
|
var state = await SchoolApiTests.GetSchoolsAsync(client);
|
||||||
|
Assert.Empty(state.Schools);
|
||||||
|
var broken = Assert.Single(state.Others);
|
||||||
|
Assert.Equal(1, broken.Id);
|
||||||
|
|
||||||
|
using var people = await client.GetAsync(
|
||||||
|
$"/api/schools/{broken.Id}/people?pageSize=1",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, people.StatusCode);
|
||||||
|
|
||||||
Assert.Equal(before, File.ReadAllBytes(path));
|
Assert.Equal(before, File.ReadAllBytes(path));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -65,29 +83,6 @@ public class ServiceabilityTests(AppHostFixture fixture)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CurrentFormatSave_LoadsAsBefore()
|
|
||||||
{
|
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
|
||||||
await SchoolApiTests.ResetAsync(client);
|
|
||||||
var directory = await SavesDirectoryAsync(client);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
InstallGolden(directory, "current");
|
|
||||||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
|
||||||
reload.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var school = Assert.Single((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
|
|
||||||
Assert.Equal(1, school.Id);
|
|
||||||
Assert.Equal("Золотая", school.Name);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await SchoolApiTests.ResetAsync(client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Dump_ReturnsPeopleNodesAndTimetable()
|
public async Task Dump_ReturnsPeopleNodesAndTimetable()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user