Merge branch 'phase/39-school-owners'
This commit is contained in:
@@ -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", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ const ru = {
|
||||
sessionLogout: 'Выйти',
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
schoolsMine: 'Мои',
|
||||
schoolsOthers: 'Чужие',
|
||||
schoolOwner: 'Хозяин: {owner}',
|
||||
schoolOwnerless: '—',
|
||||
createSchool: 'Создать школу',
|
||||
settings: 'Настройки',
|
||||
save: 'Сохранить',
|
||||
@@ -376,6 +380,10 @@ const en: Messages = {
|
||||
sessionLogout: 'Sign out',
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
schoolsMine: 'Mine',
|
||||
schoolsOthers: 'Others',
|
||||
schoolOwner: 'Owner: {owner}',
|
||||
schoolOwnerless: '—',
|
||||
createSchool: 'Create school',
|
||||
settings: 'Settings',
|
||||
save: 'Save',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -88,6 +88,7 @@ function school(): School {
|
||||
running: false,
|
||||
speedIndex: 0,
|
||||
seed: 1,
|
||||
mine: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ElementOptions {
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
rows?: number;
|
||||
autocomplete?: string;
|
||||
maxlength?: string;
|
||||
dataset?: Record<string, string>;
|
||||
onClick?: (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;
|
||||
}
|
||||
|
||||
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) {
|
||||
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
|
||||
}
|
||||
|
||||
@@ -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<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 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<HTMLElement>('.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';
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
deleteSchool,
|
||||
fetchRandomName,
|
||||
fetchSchools,
|
||||
type OtherSchool,
|
||||
type School,
|
||||
type SchoolsResponse,
|
||||
} from '../net/api.ts';
|
||||
@@ -11,7 +12,7 @@ import { schoolWord, t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { confirmDialog } from './confirmDialog.ts';
|
||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||
import { SchoolCard } from './schoolCard.ts';
|
||||
import { SchoolCard, type MenuSchool } from './schoolCard.ts';
|
||||
|
||||
interface MainMenuOptions {
|
||||
readonly onOpenSchool: (school: School) => void;
|
||||
@@ -27,10 +28,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',
|
||||
@@ -43,7 +52,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<number, SchoolCard>();
|
||||
private readonly cards = new Map<number, CardEntry>();
|
||||
|
||||
private state: SchoolsResponse | null = null;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -65,7 +74,10 @@ export class MainMenu {
|
||||
this.limitHint,
|
||||
this.status,
|
||||
this.emptyHint,
|
||||
this.grid,
|
||||
this.mineHeading,
|
||||
this.mineGrid,
|
||||
this.othersHeading,
|
||||
this.othersGrid,
|
||||
);
|
||||
|
||||
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. */
|
||||
localize(): void {
|
||||
this.title.textContent = t('schoolsTitle');
|
||||
this.mineHeading.textContent = t('schoolsMine');
|
||||
this.othersHeading.textContent = t('schoolsOthers');
|
||||
this.createButton.textContent = t('createSchool');
|
||||
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();
|
||||
@@ -96,8 +110,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);
|
||||
}
|
||||
|
||||
@@ -151,35 +163,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<number>();
|
||||
|
||||
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<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({
|
||||
title: t('deleteSchoolTitle'),
|
||||
message: t('deleteSchoolMessage', { name: school.name }),
|
||||
@@ -220,3 +264,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 };
|
||||
}
|
||||
|
||||
@@ -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 { 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
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 { 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.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<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.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();
|
||||
}
|
||||
|
||||
SwarmUiConfigFile? portraitSettings = null;
|
||||
if (request.PortraitSettings is not null)
|
||||
{
|
||||
@@ -70,6 +98,7 @@ internal static class SchoolEndpoints
|
||||
request.CountryId,
|
||||
request.NativeLanguage,
|
||||
request.Seed,
|
||||
owner,
|
||||
portraitSettings,
|
||||
NewCompletion<SchoolCreationOutcome>());
|
||||
commands.Enqueue(command);
|
||||
@@ -79,9 +108,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 =>
|
||||
@@ -111,9 +142,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<bool>());
|
||||
commands.Enqueue(command);
|
||||
|
||||
@@ -371,9 +421,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<DressRulesOutcome>());
|
||||
commands.Enqueue(command);
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
@@ -384,9 +448,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)
|
||||
{
|
||||
@@ -419,8 +497,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)
|
||||
{
|
||||
@@ -435,10 +526,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))
|
||||
{
|
||||
@@ -457,10 +561,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))
|
||||
{
|
||||
@@ -479,10 +596,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))
|
||||
{
|
||||
@@ -827,18 +957,35 @@ internal sealed record SchoolResponse(
|
||||
bool Running,
|
||||
byte SpeedIndex,
|
||||
IReadOnlyList<string> 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<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>
|
||||
internal sealed record SchoolsResponse(
|
||||
int MaxSchools,
|
||||
int MaxSchoolsTotal,
|
||||
DateTime DefaultStartDate,
|
||||
double GameMinutesPerRealSecond,
|
||||
int SchoolWeekDays,
|
||||
IReadOnlyList<SchoolResponse> Schools);
|
||||
IReadOnlyList<SchoolResponse> Schools,
|
||||
IReadOnlyList<OtherSchoolResponse> Others);
|
||||
|
||||
internal sealed record RandomNameResponse(string Name);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ internal abstract record GameCommand
|
||||
string? CountryId,
|
||||
string? NativeLanguage,
|
||||
int? Seed,
|
||||
string Owner,
|
||||
SwarmUiConfigFile? PortraitSettings,
|
||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||
|
||||
@@ -35,11 +36,11 @@ internal abstract record GameCommand
|
||||
/// </summary>
|
||||
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>
|
||||
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
|
||||
|
||||
@@ -99,6 +99,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);
|
||||
@@ -109,8 +141,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);
|
||||
|
||||
@@ -181,15 +214,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:
|
||||
@@ -341,7 +374,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;
|
||||
@@ -443,6 +482,7 @@ internal sealed class GameLoopService(
|
||||
climatePresetId,
|
||||
nativeLanguage,
|
||||
seed,
|
||||
owner: command.Owner,
|
||||
portraitSettings: portrait);
|
||||
Track(worker);
|
||||
worker.Start();
|
||||
@@ -587,6 +627,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();
|
||||
@@ -611,19 +668,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)
|
||||
@@ -643,6 +700,7 @@ internal sealed class GameLoopService(
|
||||
createSeed: null,
|
||||
save.Presence,
|
||||
save.DressRules,
|
||||
save.Owner,
|
||||
save.PortraitSettings);
|
||||
worker.Start();
|
||||
|
||||
@@ -700,6 +758,7 @@ internal sealed class GameLoopService(
|
||||
int? createSeed = null,
|
||||
IReadOnlyList<PresenceSnapshot>? presence = null,
|
||||
SchoolDressRules? dressRules = null,
|
||||
string? owner = null,
|
||||
SwarmUiConfigFile? portraitSettings = null) =>
|
||||
new(
|
||||
id,
|
||||
@@ -716,6 +775,7 @@ internal sealed class GameLoopService(
|
||||
createSeed,
|
||||
presence,
|
||||
dressRules,
|
||||
owner,
|
||||
portraitSettings,
|
||||
_options,
|
||||
clients,
|
||||
@@ -792,6 +852,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)
|
||||
|
||||
@@ -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,
|
||||
IReadOnlyList<string> ModIds,
|
||||
int Seed,
|
||||
string? Owner = null,
|
||||
bool Incompatible = false);
|
||||
|
||||
/// <summary>Everything the main menu needs in one read.</summary>
|
||||
|
||||
@@ -36,6 +36,9 @@ internal sealed class SchoolSave
|
||||
|
||||
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>
|
||||
public SwarmUiConfigFile? PortraitSettings { get; init; }
|
||||
}
|
||||
@@ -175,6 +178,7 @@ internal sealed class SchoolStore
|
||||
NativeLanguage = save.NativeLanguage,
|
||||
Presence = save.Presence,
|
||||
DressRules = save.DressRules,
|
||||
Owner = save.Owner,
|
||||
PortraitSettings = save.PortraitSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ internal sealed class SchoolWorker
|
||||
private readonly int? _createSeed;
|
||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||
private readonly SchoolDressRules? _savedDressRules;
|
||||
private readonly string? _owner;
|
||||
private readonly SwarmUiConfigFile? _portraitSettings;
|
||||
private readonly Action<int> _onFailed;
|
||||
|
||||
@@ -75,6 +76,7 @@ internal sealed class SchoolWorker
|
||||
int? createSeed,
|
||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||
SchoolDressRules? savedDressRules,
|
||||
string? owner,
|
||||
SwarmUiConfigFile? portraitSettings,
|
||||
SimulationOptions options,
|
||||
ClientRegistry clients,
|
||||
@@ -98,6 +100,7 @@ internal sealed class SchoolWorker
|
||||
_createSeed = createSeed;
|
||||
_savedPresence = savedPresence;
|
||||
_savedDressRules = savedDressRules;
|
||||
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
|
||||
_portraitSettings = portraitSettings;
|
||||
_options = options;
|
||||
_clients = clients;
|
||||
@@ -106,7 +109,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;
|
||||
@@ -655,7 +658,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);
|
||||
@@ -978,8 +982,9 @@ internal sealed class SchoolWorker
|
||||
|
||||
if (DressGenerator.NeedsDressing(roster, applicants))
|
||||
{
|
||||
throw new SchoolContentUnavailableException(
|
||||
$"School {_id} people have no clothes; the school was left unstarted.");
|
||||
roster = DressGenerator.EnsureRoster(catalog, roster, seed, school.Clock.Time);
|
||||
applicants = DressGenerator.EnsurePool(catalog, applicants, roster, seed, school.Clock.Time);
|
||||
generated = true;
|
||||
}
|
||||
|
||||
RequireKnownApparel(catalog, roster, applicants);
|
||||
@@ -1076,6 +1081,7 @@ internal sealed class SchoolWorker
|
||||
NativeLanguage = _nativeLanguage,
|
||||
Presence = school.CapturePresence(),
|
||||
DressRules = school.DressRules,
|
||||
Owner = _owner,
|
||||
PortraitSettings = _portraitSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
},
|
||||
"Simulation": {
|
||||
"TickRate": 20,
|
||||
"MaxSchools": 6,
|
||||
"MaxSchools": 2,
|
||||
"MaxSchoolsTotal": 16,
|
||||
"GameMinutesPerRealSecond": 1,
|
||||
"DefaultStartDate": "2012-03-31T06:00:00",
|
||||
"SavesDirectory": "saves",
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum SchoolCreationError
|
||||
{
|
||||
None = 0,
|
||||
LimitReached,
|
||||
ServerFull,
|
||||
InvalidName,
|
||||
InvalidStartDate,
|
||||
InvalidMap,
|
||||
|
||||
@@ -10,8 +10,11 @@ public sealed class SimulationOptions
|
||||
/// <summary>Fixed simulation steps per second.</summary>
|
||||
public int TickRate { get; set; } = 20;
|
||||
|
||||
/// <summary>How many schools may exist at the same time.</summary>
|
||||
public int MaxSchools { get; set; } = 6;
|
||||
/// <summary>How many schools one player may own at the same time.</summary>
|
||||
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>
|
||||
public double GameMinutesPerRealSecond { get; set; } = 1d;
|
||||
|
||||
Reference in New Issue
Block a user