WIP phase 39: school owners, my/others menu, guest restrictions.

This commit is contained in:
Leonid Pershin
2026-08-20 07:58:12 +03:00
parent 40929aa3ce
commit 26be7c87f8
27 changed files with 919 additions and 81 deletions
+4 -3
View File
@@ -19,6 +19,7 @@ internal abstract record GameCommand
string? CountryId,
string? NativeLanguage,
int? Seed,
string Owner,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
@@ -34,11 +35,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;
+73 -13
View File
@@ -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<PresenceSnapshot>? 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)
@@ -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);
}
+1
View File
@@ -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>
+4
View File
@@ -35,6 +35,9 @@ internal sealed class SchoolSave
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
public SchoolDressRules? DressRules { get; init; }
/// <summary>Normalized player name. Missing or blank means ownerless.</summary>
public string? Owner { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -172,6 +175,7 @@ internal sealed class SchoolStore
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
DressRules = save.DressRules,
Owner = save.Owner,
});
}
catch (Exception ex)
+22 -2
View File
@@ -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 Action<int> _onFailed;
private readonly int _id;
@@ -74,6 +75,7 @@ internal sealed class SchoolWorker
int? createSeed,
IReadOnlyList<PresenceSnapshot>? 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)