WIP phase 39: school owners, my/others menu, guest restrictions.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user