From 26be7c87f8009434ac92217635cc6671174b52a0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 07:58:12 +0300 Subject: [PATCH] WIP phase 39: school owners, my/others menu, guest restrictions. --- docs/protocol.md | 29 ++- src/HSchool.AppHost/AppHost.cs | 1 + src/HSchool.Client/src/i18n/strings.ts | 8 + src/HSchool.Client/src/net/api.ts | 15 ++ src/HSchool.Client/src/ui/dom.ts | 10 + src/HSchool.Client/src/ui/gameScreen.test.ts | 24 +++ src/HSchool.Client/src/ui/gameScreen.ts | 16 ++ src/HSchool.Client/src/ui/mainMenu.ts | 103 ++++++++--- src/HSchool.Client/src/ui/schoolCard.test.ts | 32 ++++ src/HSchool.Client/src/ui/schoolCard.ts | 35 +++- src/HSchool.Server/Api/SchoolAccess.cs | 54 ++++++ src/HSchool.Server/Api/SchoolEndpoints.cs | 171 ++++++++++++++++-- src/HSchool.Server/Api/TimetableEndpoints.cs | 27 +++ src/HSchool.Server/Game/GameCommand.cs | 7 +- src/HSchool.Server/Game/GameLoopService.cs | 86 +++++++-- src/HSchool.Server/Game/SchoolOwnership.cs | 23 +++ src/HSchool.Server/Game/SchoolState.cs | 1 + src/HSchool.Server/Game/SchoolStore.cs | 4 + src/HSchool.Server/Game/SchoolWorker.cs | 24 ++- src/HSchool.Server/Net/GameSocketHandler.cs | 22 ++- src/HSchool.Server/Program.cs | 1 + src/HSchool.Server/appsettings.json | 3 +- src/HSchool.Simulation/SchoolCreationError.cs | 1 + src/HSchool.Simulation/SimulationOptions.cs | 7 +- .../HSchool.AppHost.Tests/GameSocketTests.cs | 35 +++- tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 97 +++++++++- .../SchoolOwnerApiTests.cs | 164 +++++++++++++++++ 27 files changed, 919 insertions(+), 81 deletions(-) create mode 100644 src/HSchool.Client/src/ui/schoolCard.test.ts create mode 100644 src/HSchool.Server/Api/SchoolAccess.cs create mode 100644 src/HSchool.Server/Game/SchoolOwnership.cs create mode 100644 tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs diff --git a/docs/protocol.md b/docs/protocol.md index 60e39c0..da1de52 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -76,16 +76,26 @@ people; it does not change on a living school. ```json { - "maxSchools": 6, + "maxSchools": 2, + "maxSchoolsTotal": 16, "defaultStartDate": "2012-03-31T06:00:00Z", "gameMinutesPerRealSecond": 1, "schoolWeekDays": 5, "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` `{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken. @@ -191,13 +201,15 @@ the server rolls one. Existing saves keep the seed already stored in the people | `400` `invalid-catalog` | The selected packs could not be loaded. | | `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. | | `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. | -| `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. ### `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. ### `GET /api/schools/{id}/people` @@ -456,7 +468,8 @@ is off or unreachable, `connected` is false and the lists are empty. ### `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`, `free`. When a `POST` has been accepted but not yet applied, `pendingStudents` and/or `pendingStaff` @@ -474,7 +487,7 @@ not immediately. ### `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 `color` is `400` `unknown-form` / `400` `unknown-color`. Response body matches `GET`. @@ -484,7 +497,8 @@ omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`. ### `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 `404` `unknown-school`. `?lang=ru|en` labels subjects and positions. @@ -578,6 +592,7 @@ of that kind. | Status | `code` | When | | --- | --- | --- | +| `403` | `not-owner` | The session is not the school's owner. | | `404` | `unknown-school` | No school with that id. | | `404` | `unknown-applicant` | `personId` is not in the pool. | | `409` | `already-hired` | That person is already staff. | diff --git a/src/HSchool.AppHost/AppHost.cs b/src/HSchool.AppHost/AppHost.cs index 16ca242..5464a3c 100644 --- a/src/HSchool.AppHost/AppHost.cs +++ b/src/HSchool.AppHost/AppHost.cs @@ -17,6 +17,7 @@ if (headless) .WithEnvironment("Simulation__SavesDirectory", saves) .WithEnvironment("HSchool__AllowSaveReload", "true") .WithEnvironment("HSchool__AlphaPassword", "test-alpha") + .WithEnvironment("Simulation__MaxSchoolsTotal", "7") .WithEnvironment("SwarmUi__BaseUrl", ""); } diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index d27ad6c..c884b5e 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -23,6 +23,10 @@ const ru = { sessionLogout: 'Выйти', schoolsTitle: 'Школы', + schoolsMine: 'Мои', + schoolsOthers: 'Чужие', + schoolOwner: 'Хозяин: {owner}', + schoolOwnerless: '—', createSchool: 'Создать школу', settings: 'Настройки', save: 'Сохранить', @@ -365,6 +369,10 @@ const en: Messages = { sessionLogout: 'Sign out', schoolsTitle: 'Schools', + schoolsMine: 'Mine', + schoolsOthers: 'Others', + schoolOwner: 'Owner: {owner}', + schoolOwnerless: '—', createSchool: 'Create school', settings: 'Settings', save: 'Save', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 32e3751..3eafa13 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -13,14 +13,29 @@ export interface School { readonly modIds?: readonly string[]; /** Roster generator seed. Independent of `id`; share it to recreate the same people. */ 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 { readonly maxSchools: number; + readonly maxSchoolsTotal: number; readonly defaultStartDate: string; readonly gameMinutesPerRealSecond: number; readonly schoolWeekDays: number; readonly schools: readonly School[]; + readonly others: readonly OtherSchool[]; } /** A failed request, with the machine-readable `code` the server puts in its problem details. */ diff --git a/src/HSchool.Client/src/ui/dom.ts b/src/HSchool.Client/src/ui/dom.ts index 2f5622d..7fa470b 100644 --- a/src/HSchool.Client/src/ui/dom.ts +++ b/src/HSchool.Client/src/ui/dom.ts @@ -14,6 +14,8 @@ interface ElementOptions { placeholder?: string; value?: string; rows?: number; + autocomplete?: string; + maxlength?: string; dataset?: Record; onClick?: (event: Event) => void; onInput?: (event: Event) => void; @@ -44,6 +46,14 @@ export function el( (element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder; } + if (options.autocomplete !== undefined && 'autocomplete' in element) { + (element as HTMLInputElement).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) { (element as HTMLInputElement | HTMLTextAreaElement).value = options.value; } diff --git a/src/HSchool.Client/src/ui/gameScreen.test.ts b/src/HSchool.Client/src/ui/gameScreen.test.ts index 823ca01..567b5f6 100644 --- a/src/HSchool.Client/src/ui/gameScreen.test.ts +++ b/src/HSchool.Client/src/ui/gameScreen.test.ts @@ -22,4 +22,28 @@ describe('GameScreen speed buttons', () => { 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('.mode-tab')[1]; + expect(manageTab?.hidden).toBe(true); + expect(screen.element.querySelector('.clock__controls')?.hidden).toBe(true); + }); }); diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index ed65350..8ed4a87 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -82,6 +82,7 @@ export class GameScreen { private directoryToken = 0; private selectedId: string | null = null; private schoolId: number | null = null; + private canManage = true; private peopleSeed: number | null = null; private running = false; 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. */ show(school: School): void { + this.canManage = school.mine; + this.manageTab.hidden = !this.canManage; + if (!this.canManage) { + this.showMode('overview'); + } + + const clockControls = this.root.querySelector('.clock__controls'); + if (clockControls !== null) { + clockControls.hidden = !this.canManage; + } + this.schoolId = school.id; this.peopleSeed = school.seed; this.schoolName.textContent = school.name; @@ -311,6 +323,10 @@ export class GameScreen { } private showMode(mode: 'overview' | 'manage'): void { + if (mode === 'manage' && !this.canManage) { + mode = 'overview'; + } + this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview'); this.manageTab.classList.toggle('mode-tab--active', mode === 'manage'); this.overview.hidden = mode !== 'overview'; diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 1aefd8d..aeae31c 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -3,6 +3,7 @@ import { deleteSchool, fetchRandomName, fetchSchools, + type OtherSchool, type School, type SchoolsResponse, } from '../net/api.ts'; @@ -12,7 +13,7 @@ import { el } from './dom.ts'; import { confirmDialog } from './confirmDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts'; import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts'; -import { SchoolCard } from './schoolCard.ts'; +import { SchoolCard, type MenuSchool } from './schoolCard.ts'; interface MainMenuOptions { readonly onOpenSchool: (school: School) => void; @@ -28,10 +29,18 @@ interface MainMenuOptions { */ const REFRESH_INTERVAL_MS = 1000; +interface CardEntry { + readonly card: SchoolCard; + readonly section: 'mine' | 'other'; +} + export class MainMenu { private readonly root = el('section', { class: 'screen menu' }); 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 createButton = el('button', { class: 'button button--primary', @@ -48,7 +57,7 @@ export class MainMenu { private readonly limitHint = el('p', { class: 'hint' }); private readonly status = el('p', { class: 'hint hint--error' }); - private readonly cards = new Map(); + private readonly cards = new Map(); private state: SchoolsResponse | null = null; private refreshTimer: ReturnType | null = null; @@ -71,7 +80,10 @@ export class MainMenu { this.limitHint, this.status, this.emptyHint, - this.grid, + this.mineHeading, + this.mineGrid, + this.othersHeading, + this.othersGrid, ); this.localize(); @@ -84,13 +96,15 @@ export class MainMenu { /** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */ localize(): void { this.title.textContent = t('schoolsTitle'); + this.mineHeading.textContent = t('schoolsMine'); + this.othersHeading.textContent = t('schoolsOthers'); this.createButton.textContent = t('createSchool'); this.settingsButton.textContent = t('settings'); this.logoutButton.textContent = t('sessionLogout'); this.emptyHint.textContent = t('emptySchools'); - for (const card of this.cards.values()) { - card.localize(); + for (const entry of this.cards.values()) { + entry.card.localize(); } this.paintError(); @@ -103,8 +117,6 @@ export class MainMenu { 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); } @@ -158,35 +170,67 @@ export class MainMenu { : t('schoolCount', { current: state.schools.length, max: state.maxSchools }); 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(); + for (const school of state.schools) { seen.add(school.id); - - const card = this.cards.get(school.id); - if (card === undefined) { - 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); - } + this.patchCard(school, 'mine', this.mineGrid, { + canDelete: true, + ownerLabel: null, + }); } - 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)) { - card.element.remove(); + entry.card.element.remove(); this.cards.delete(id); } } } - private async confirmDelete(school: School): Promise { + 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 { const confirmed = await confirmDialog({ title: t('deleteSchoolTitle'), message: t('deleteSchoolMessage', { name: school.name }), @@ -227,3 +271,12 @@ export class MainMenu { 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 }; +} diff --git a/src/HSchool.Client/src/ui/schoolCard.test.ts b/src/HSchool.Client/src/ui/schoolCard.test.ts new file mode 100644 index 0000000..0d8d8cb --- /dev/null +++ b/src/HSchool.Client/src/ui/schoolCard.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it, vi } 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')?.hidden).toBe(true); + }); +}); diff --git a/src/HSchool.Client/src/ui/schoolCard.ts b/src/HSchool.Client/src/ui/schoolCard.ts index 072a549..8c2222d 100644 --- a/src/HSchool.Client/src/ui/schoolCard.ts +++ b/src/HSchool.Client/src/ui/schoolCard.ts @@ -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 { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; +export type MenuSchool = School | (OtherSchool & { mine: false }); + interface SchoolCardOptions { - readonly onOpen: (school: School) => void; - readonly onDelete: (school: School) => void; + readonly onOpen: (school: MenuSchool) => 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' }); 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 pausedBadge = el('span', { class: 'card__badge' }); private readonly deleteButton = el('button', { @@ -23,13 +28,14 @@ export class SchoolCard { type: 'button', }); - private school: School; + private school: MenuSchool; - constructor(school: School, options: SchoolCardOptions) { + constructor(school: MenuSchool, options: SchoolCardOptions) { this.school = school; this.element.dataset['schoolId'] = String(school.id); this.element.tabIndex = 0; + this.deleteButton.hidden = !options.canDelete; this.deleteButton.addEventListener('click', (event) => { // The whole card is clickable, so the delete button must not open the school too. event.stopPropagation(); @@ -38,6 +44,7 @@ export class SchoolCard { this.element.append( this.title, + this.owner, el('div', { class: 'card__meta' }, this.time, this.pausedBadge), el('div', { class: 'card__actions' }, this.deleteButton), ); @@ -50,6 +57,7 @@ export class SchoolCard { } }); + this.setOwnerLabel(options.ownerLabel ?? null); this.localize(); this.update(school); } @@ -60,11 +68,26 @@ export class SchoolCard { 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.title.textContent = school.name; this.time.textContent = formatGameDateTime(new Date(school.gameTime)); this.pausedBadge.hidden = school.running; } + + setCanDelete(canDelete: boolean): void { + this.deleteButton.hidden = !canDelete; + } } diff --git a/src/HSchool.Server/Api/SchoolAccess.cs b/src/HSchool.Server/Api/SchoolAccess.cs new file mode 100644 index 0000000..9081a8e --- /dev/null +++ b/src/HSchool.Server/Api/SchoolAccess.cs @@ -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 { ["code"] = code }); +} diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index b269c1a..1a9aeeb 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -1,6 +1,7 @@ using HSchool.Content; using HSchool.People; using HSchool.Server.Game; +using HSchool.Server.Session; using HSchool.Simulation; namespace HSchool.Server.Api; @@ -19,17 +20,37 @@ internal static class SchoolEndpoints { var schools = builder.MapGroup("/api/schools"); - schools.MapGet("/", (GameLoopService loop) => + schools.MapGet("/", (HttpContext context, GameLoopService loop, SessionService sessions, UserStore users) => { - var state = loop.SchoolsState; - var options = loop.Options; + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser)) + { + return Results.Unauthorized(); + } - return new SchoolsResponse( - state.MaxSchools, + var options = loop.Options; + var mine = new List(); + var others = new List(); + + 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.GameMinutesPerRealSecond, options.SchoolWeekDays, - [.. state.Schools.Select(SchoolResponse.From)]); + mine, + others)); }) .WithName("GetSchools"); @@ -45,9 +66,16 @@ internal static class SchoolEndpoints schools.MapPost("/", async ( CreateSchoolRequest request, + HttpContext context, + SessionService sessions, GameCommandQueue commands, CancellationToken cancellationToken) => { + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var owner)) + { + return Results.Unauthorized(); + } + var command = new GameCommand.CreateSchool( request.Name ?? string.Empty, DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), @@ -56,6 +84,7 @@ internal static class SchoolEndpoints request.CountryId, request.NativeLanguage, request.Seed, + owner, NewCompletion()); commands.Enqueue(command); @@ -64,9 +93,11 @@ internal static class SchoolEndpoints return outcome.Error switch { 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 => - 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 => Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."), SchoolCreationError.InvalidStartDate => @@ -96,9 +127,28 @@ internal static class SchoolEndpoints schools.MapDelete("/{id:int}", async ( int id, + HttpContext context, + SessionService sessions, + GameLoopService loop, GameCommandQueue commands, 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()); commands.Enqueue(command); @@ -356,9 +406,23 @@ internal static class SchoolEndpoints schools.MapGet("/{id:int}/dress-rules", async ( int id, + HttpContext context, + SessionService sessions, + GameLoopService loop, GameCommandQueue commands, 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()); commands.Enqueue(command); var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); @@ -369,9 +433,23 @@ internal static class SchoolEndpoints schools.MapPost("/{id:int}/dress-rules", async ( int id, SetDressRulesRequest request, + HttpContext context, + SessionService sessions, + GameLoopService loop, GameCommandQueue commands, 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; if (request.Students is { } studentDto) { @@ -404,8 +482,21 @@ internal static class SchoolEndpoints schools.MapGet("/{id:int}/staffing", ( int id, string? lang, + HttpContext context, + SessionService sessions, 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); if (published is null) { @@ -420,10 +511,23 @@ internal static class SchoolEndpoints int id, HireStaffRequest request, string? lang, + HttpContext context, + SessionService sessions, GameCommandQueue commands, GameLoopService loop, 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) || !TryDefName(request.Position, "position", out var position, out error)) { @@ -442,10 +546,23 @@ internal static class SchoolEndpoints string personId, AssignSubjectRequest request, string? lang, + HttpContext context, + SessionService sessions, GameCommandQueue commands, GameLoopService loop, 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) || !TryDefName(request.Subject, "subject", out var subject, out error)) { @@ -464,10 +581,23 @@ internal static class SchoolEndpoints string personId, string subject, string? lang, + HttpContext context, + SessionService sessions, GameCommandQueue commands, GameLoopService loop, 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) || !TryDefName(subject, "subject", out var subjectName, out error)) { @@ -811,18 +941,35 @@ internal sealed record SchoolResponse( bool Running, byte SpeedIndex, IReadOnlyList ModIds, - int Seed) + int Seed, + bool Mine) { - public static SchoolResponse From(SchoolState school) => - new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds, school.Seed); + public static SchoolResponse From(SchoolState school, bool mine) => + 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 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); } /// Everything the main menu needs in one request. internal sealed record SchoolsResponse( int MaxSchools, + int MaxSchoolsTotal, DateTime DefaultStartDate, double GameMinutesPerRealSecond, int SchoolWeekDays, - IReadOnlyList Schools); + IReadOnlyList Schools, + IReadOnlyList Others); internal sealed record RandomNameResponse(string Name); diff --git a/src/HSchool.Server/Api/TimetableEndpoints.cs b/src/HSchool.Server/Api/TimetableEndpoints.cs index 82967a4..adc34b6 100644 --- a/src/HSchool.Server/Api/TimetableEndpoints.cs +++ b/src/HSchool.Server/Api/TimetableEndpoints.cs @@ -1,6 +1,7 @@ using HSchool.People; using HSchool.Schedule; using HSchool.Server.Game; +using HSchool.Server.Session; namespace HSchool.Server.Api; @@ -33,10 +34,23 @@ internal static class TimetableEndpoints int id, PinLessonRequest request, string? lang, + HttpContext context, + SessionService sessions, GameCommandQueue commands, GameLoopService loop, 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) || !TryDefName(request.Subject, "subject", out var subject, out error) || !TryDefName(request.RoomId, "roomId", out var roomId, out error)) @@ -65,10 +79,23 @@ internal static class TimetableEndpoints int? day, int? period, string? lang, + HttpContext context, + SessionService sessions, GameCommandQueue commands, GameLoopService loop, 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) || !TryDefName(subject, "subject", out var subjectValue, out error) || day is null diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index e1e2f0c..d8af893 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -19,6 +19,7 @@ internal abstract record GameCommand string? CountryId, string? NativeLanguage, int? Seed, + string Owner, TaskCompletionSource Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; @@ -34,11 +35,11 @@ internal abstract record 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; /// Stops every worker, re-reads the save directory, starts workers from those files. internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 3fd69cd..12b33a8 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -84,6 +84,38 @@ internal sealed class GameLoopService( 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) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -94,8 +126,9 @@ internal sealed class GameLoopService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { 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.MaxSchoolsTotal, _options.MaxSchools, store.DirectoryPath); @@ -166,15 +199,15 @@ internal sealed class GameLoopService( break; case GameCommand.SetRunning setRunning: - RouteOpenSchool(setRunning.PlayerId, new WorkerCommand.SetRunning(setRunning.Running)); + HandleClockCommand(setRunning.PlayerId, setRunning.NormalizedUserName, new WorkerCommand.SetRunning(setRunning.Running)); break; case GameCommand.SetSpeed setSpeed: - RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex)); + HandleClockCommand(setSpeed.PlayerId, setSpeed.NormalizedUserName, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex)); break; case GameCommand.SkipEmpty skipEmpty: - RouteOpenSchool(skipEmpty.PlayerId, new WorkerCommand.SkipEmpty()); + HandleClockCommand(skipEmpty.PlayerId, skipEmpty.NormalizedUserName, new WorkerCommand.SkipEmpty()); break; case GameCommand.ReloadSaves reload: @@ -326,7 +359,13 @@ internal sealed class GameLoopService( { 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)); return; @@ -414,7 +453,7 @@ internal sealed class GameLoopService( var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true); var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true); - var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, countryId, climatePresetId, nativeLanguage, seed); + var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, countryId, climatePresetId, nativeLanguage, seed, owner: command.Owner); Track(worker); worker.Start(); @@ -558,6 +597,23 @@ internal sealed class GameLoopService( 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() { var saves = store.LoadAll(); @@ -582,19 +638,19 @@ internal sealed class GameLoopService( } } - if (startable.Count > _options.MaxSchools) + if (startable.Count > _options.MaxSchoolsTotal) { logger.LogWarning( "Found {Count} runnable school saves but the limit is {Max}; starting the first {Max}.", startable.Count, - _options.MaxSchools, - _options.MaxSchools); - foreach (var extra in startable.Skip(_options.MaxSchools)) + _options.MaxSchoolsTotal, + _options.MaxSchoolsTotal); + foreach (var extra in startable.Skip(_options.MaxSchoolsTotal)) { RememberIncompatible(FromSave(extra)); } - startable = [.. startable.Take(_options.MaxSchools)]; + startable = [.. startable.Take(_options.MaxSchoolsTotal)]; } foreach (var save in startable) @@ -613,7 +669,8 @@ internal sealed class GameLoopService( save.NativeLanguage, createSeed: null, save.Presence, - save.DressRules); + save.DressRules, + save.Owner); worker.Start(); try @@ -669,7 +726,8 @@ internal sealed class GameLoopService( string? nativeLanguage, int? createSeed = null, IReadOnlyList? presence = null, - SchoolDressRules? dressRules = null) => + SchoolDressRules? dressRules = null, + string? owner = null) => new( id, name, @@ -685,6 +743,7 @@ internal sealed class GameLoopService( createSeed, presence, dressRules, + owner, _options, clients, metrics, @@ -760,6 +819,7 @@ internal sealed class GameLoopService( (byte)Math.Clamp(save.SpeedIndex, 0, 255), save.ModIds ?? [], SeedOf(save.Id), + Owner: string.IsNullOrWhiteSpace(save.Owner) ? null : save.Owner, Incompatible: true); private int SeedOf(int schoolId) diff --git a/src/HSchool.Server/Game/SchoolOwnership.cs b/src/HSchool.Server/Game/SchoolOwnership.cs new file mode 100644 index 0000000..24880a1 --- /dev/null +++ b/src/HSchool.Server/Game/SchoolOwnership.cs @@ -0,0 +1,23 @@ +namespace HSchool.Server.Game; + +/// Who may delete or manage a school. Ownerless saves can be deleted by anyone logged in. +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); +} diff --git a/src/HSchool.Server/Game/SchoolState.cs b/src/HSchool.Server/Game/SchoolState.cs index cc2c6c2..35b624c 100644 --- a/src/HSchool.Server/Game/SchoolState.cs +++ b/src/HSchool.Server/Game/SchoolState.cs @@ -12,6 +12,7 @@ internal sealed record SchoolState( byte SpeedIndex, IReadOnlyList ModIds, int Seed, + string? Owner = null, bool Incompatible = false); /// Everything the main menu needs in one read. diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 6ca6c6f..7036150 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -35,6 +35,9 @@ internal sealed class SchoolSave public IReadOnlyList? Presence { get; init; } public SchoolDressRules? DressRules { get; init; } + + /// Normalized player name. Missing or blank means ownerless. + public string? Owner { get; init; } } /// Allocates school ids that survive a process restart. @@ -172,6 +175,7 @@ internal sealed class SchoolStore NativeLanguage = save.NativeLanguage, Presence = save.Presence, DressRules = save.DressRules, + Owner = save.Owner, }); } catch (Exception ex) diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index d09a9e5..c3c74da 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -38,6 +38,7 @@ internal sealed class SchoolWorker private readonly int? _createSeed; private readonly IReadOnlyList? _savedPresence; private readonly SchoolDressRules? _savedDressRules; + private readonly string? _owner; private readonly Action _onFailed; private readonly int _id; @@ -74,6 +75,7 @@ internal sealed class SchoolWorker int? createSeed, IReadOnlyList? savedPresence, SchoolDressRules? savedDressRules, + string? owner, SimulationOptions options, ClientRegistry clients, GameMetrics metrics, @@ -96,6 +98,7 @@ internal sealed class SchoolWorker _createSeed = createSeed; _savedPresence = savedPresence; _savedDressRules = savedDressRules; + _owner = string.IsNullOrWhiteSpace(owner) ? null : owner; _options = options; _clients = clients; _metrics = metrics; @@ -103,7 +106,7 @@ internal sealed class SchoolWorker _mods = mods; _onFailed = onFailed; _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; @@ -649,7 +652,8 @@ internal sealed class SchoolWorker school.Clock.IsRunning, (byte)school.Clock.SpeedIndex, school.Catalog?.PackIds ?? _modIds ?? [], - school.PeopleSeed)); + school.PeopleSeed, + _owner)); Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _timetableSnapshot, school.Timetable); @@ -994,6 +998,21 @@ internal sealed class SchoolWorker return generated; } + private static void RequireKnownApparel(DefCatalog catalog, Roster roster, ApplicantPool applicants) + { + foreach (var person in roster.People.Concat(applicants.Applicants.Select(row => row.Person))) + { + foreach (var item in person.Items) + { + if (!catalog.Things.TryGetValue(item.Def, out var def) || def.Abstract) + { + throw new SchoolContentUnavailableException( + $"School roster references unusable thing '{item.Def}'."); + } + } + } + } + private static string? ResolveCountryId(DefCatalog catalog, string? requested) { if (string.IsNullOrWhiteSpace(requested)) @@ -1049,6 +1068,7 @@ internal sealed class SchoolWorker NativeLanguage = _nativeLanguage, Presence = school.CapturePresence(), DressRules = school.DressRules, + Owner = _owner, }); } catch (Exception ex) diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index 2f62fd5..5e09ebc 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Net.WebSockets; using HSchool.Protocol; using HSchool.Server.Game; +using HSchool.Simulation; namespace HSchool.Server.Net; @@ -146,16 +147,28 @@ internal sealed class GameSocketHandler( case MessageType.ClientSetRunning: 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; case MessageType.ClientSetSpeed: 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; 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; default: @@ -227,6 +240,9 @@ internal sealed class GameSocketHandler( 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( WebSocket socket, WebSocketCloseStatus status, diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 83f199a..0c862cd 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -27,6 +27,7 @@ builder.Services .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.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 => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.") .Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.") diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index 3f368b0..7e9aeb3 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -17,7 +17,8 @@ }, "Simulation": { "TickRate": 20, - "MaxSchools": 6, + "MaxSchools": 2, + "MaxSchoolsTotal": 16, "GameMinutesPerRealSecond": 1, "DefaultStartDate": "2012-03-31T06:00:00", "SavesDirectory": "saves", diff --git a/src/HSchool.Simulation/SchoolCreationError.cs b/src/HSchool.Simulation/SchoolCreationError.cs index e2dea68..5af73a4 100644 --- a/src/HSchool.Simulation/SchoolCreationError.cs +++ b/src/HSchool.Simulation/SchoolCreationError.cs @@ -8,6 +8,7 @@ public enum SchoolCreationError { None = 0, LimitReached, + ServerFull, InvalidName, InvalidStartDate, InvalidMap, diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index e9ecd09..8b41b7f 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -10,8 +10,11 @@ public sealed class SimulationOptions /// Fixed simulation steps per second. public int TickRate { get; set; } = 20; - /// How many schools may exist at the same time. - public int MaxSchools { get; set; } = 6; + /// How many schools one player may own at the same time. + public int MaxSchools { get; set; } = 2; + + /// How many school workers the process may run at once. + public int MaxSchoolsTotal { get; set; } = 16; /// Base speed of the game clock: real seconds are multiplied by this many game minutes. public double GameMinutesPerRealSecond { get; set; } = 1d; diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 1df0a80..2e8e38f 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -22,7 +22,7 @@ public class GameSocketTests(AppHostFixture fixture) Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion); Assert.Equal(20, welcome.TickRate); - Assert.Equal(6, welcome.MaxSchools); + Assert.Equal(2, welcome.MaxSchools); } [Fact] @@ -387,6 +387,39 @@ public class GameSocketTests(AppHostFixture fixture) 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] public async Task ReloadFromDisk_RestoresAPausedClock() { diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 8dc8a4c..2621a28 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -19,7 +19,8 @@ public class SchoolApiTests(AppHostFixture fixture) 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); // 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.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."); } @@ -526,6 +527,21 @@ public class SchoolApiTests(AppHostFixture fixture) _ = await LoginAndGetCookieAsync(client, userName); } + internal static async Task 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 LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName) { using var first = await client.PostAsJsonAsync( @@ -585,6 +601,62 @@ public class SchoolApiTests(AppHostFixture fixture) using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); } + + foreach (var school in state.Others) + { + using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); + if (response.StatusCode == HttpStatusCode.Forbidden) + { + continue; + } + + response.EnsureSuccessStatusCode(); + } + } + + internal static async Task WipeAllSavesAsync(HttpClient client) + { + await LoginAsync(client); + + for (var pass = 0; pass < 4; pass++) + { + var state = await GetSchoolsAsync(client); + if (state.Schools.Count == 0 && state.Others.Count == 0) + { + break; + } + + 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(); + } + } + + var directory = await SavesDirectoryAsync(client); + foreach (var path in Directory.EnumerateFiles(directory)) + { + File.Delete(path); + } + + foreach (var path in Directory.EnumerateDirectories(directory)) + { + Directory.Delete(path, recursive: true); + } + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); } internal static async Task GetSchoolsAsync(HttpClient client) @@ -649,7 +721,7 @@ public class SchoolApiTests(AppHostFixture fixture) return save; } - private static async Task SavesDirectoryAsync(HttpClient client) + internal static async Task SavesDirectoryAsync(HttpClient client) { var payload = await client.GetFromJsonAsync( "/api/dev/saves-directory", @@ -659,7 +731,7 @@ public class SchoolApiTests(AppHostFixture fixture) return payload.Path; } - private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage); + private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage, string? Owner); internal static readonly object SimpleCustomMap = new { @@ -673,7 +745,7 @@ public class SchoolApiTests(AppHostFixture fixture) links = new[] { new { a = "yard", b = "office" } }, }; - private static Task PostAsync( + internal static Task PostAsync( HttpClient client, string name, DateTime startDate, @@ -696,14 +768,27 @@ public class SchoolApiTests(AppHostFixture fixture) bool Running, byte SpeedIndex, int Seed, + bool Mine, + IReadOnlyList? ModIds = null); + + internal sealed record OtherSchoolResponse( + int Id, + string Name, + DateTime GameTime, + bool Running, + byte SpeedIndex, + int Seed, + string? Owner, IReadOnlyList? ModIds = null); internal sealed record SchoolsResponse( int MaxSchools, + int MaxSchoolsTotal, DateTime DefaultStartDate, double GameMinutesPerRealSecond, int SchoolWeekDays, - IReadOnlyList Schools); + IReadOnlyList Schools, + IReadOnlyList Others); private sealed record RandomNameResponse(string Name); diff --git a/tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs new file mode 100644 index 0000000..81618f7 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/SchoolOwnerApiTests.cs @@ -0,0 +1,164 @@ +using System.Net.Http.Json; + +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)); + } + + [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(); + 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(); + } + } + } + + [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)); + } + + [Fact] + public async Task OwnerlessSave_AppearsInOthers_AndCanBeDeletedByAnotherUser() + { + using var wipe = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.WipeAllSavesAsync(wipe); + + var first = await CreateUserAsync("SaveAlpha"); + var directory = await SavesDirectoryAsync(first); + var golden = Path.Combine(AppContext.BaseDirectory, "golden", "current"); + foreach (var file in Directory.EnumerateFiles(golden)) + { + File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: 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); + } + + private async Task 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 SavesDirectoryAsync(HttpClient client) => + await SchoolApiTests.SavesDirectoryAsync(client); +}