Files
h-school/src/HSchool.Server/Game/GameLoopService.cs
T
Leonid Pershin 81fd3f6181 Split SwarmUI portraits into a model catalog and layered prompts.
Picker is Swarm intersect config, preset style/shot inherit model defaults, and living saves lift the old preset shape.
2026-08-20 14:08:11 +03:00

906 lines
30 KiB
C#

using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Server.Api;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Thin supervisor: create/delete/load schools, route commands into per-school mailboxes, expose
/// published snapshots to the menu. It never touches a <see cref="School"/> or its <c>World</c>.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
GameCommandQueue commands,
ClientRegistry clients,
GameMetrics metrics,
SchoolStore store,
ModContent mods,
SwarmUiSettingsStore swarmSettings,
ILoggerFactory loggerFactory,
ILogger<GameLoopService> logger) : BackgroundService
{
private readonly SimulationOptions _options = options.Value;
private readonly SchoolNameGenerator _names = new();
private readonly Dictionary<int, SchoolWorker> _workers = [];
private readonly Dictionary<int, SchoolState> _incompatible = [];
private readonly List<int> _order = [];
private SchoolWorker[] _publishedWorkers = [];
private SchoolState[] _publishedIncompatible = [];
private int _currentTick;
private int _nextId = 1;
public uint CurrentTick => (uint)Volatile.Read(ref _currentTick);
public SimulationOptions Options => _options;
/// <summary>
/// Menu requests read this instead of blocking a worker. Each card's clock is whatever that
/// school's worker last published.
/// </summary>
public SchoolsState SchoolsState
{
get
{
var workers = Volatile.Read(ref _publishedWorkers);
var broken = Volatile.Read(ref _publishedIncompatible);
var schools = new List<SchoolState>(workers.Length + broken.Length);
for (var i = 0; i < workers.Length; i++)
{
schools.Add(workers[i].Snapshot);
}
schools.AddRange(broken);
schools.Sort((left, right) => left.Id.CompareTo(right.Id));
return new SchoolsState(_options.MaxSchools, schools);
}
}
/// <summary>Create-time SwarmUI copy for this living school, or null on older saves.</summary>
public SwarmUiConfigFile? PortraitSettingsOf(int schoolId)
{
foreach (var worker in Volatile.Read(ref _publishedWorkers))
{
if (worker.Id == schoolId)
{
return worker.PortraitSettings;
}
}
return null;
}
/// <summary>
/// Menu-style read of one school's published roster and frozen catalog. Does not post to the
/// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks.
/// </summary>
public PublishedSchoolPeople? FindPeople(int schoolId)
{
foreach (var worker in Volatile.Read(ref _publishedWorkers))
{
if (worker.Id == schoolId)
{
return new PublishedSchoolPeople(
worker.Snapshot,
worker.RosterSnapshot,
worker.ApplicantSnapshot,
worker.CatalogSnapshot,
worker.TimetableSnapshot,
worker.MapSnapshot);
}
}
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);
commands.Enqueue(new GameCommand.ReloadSaves(completion));
return completion.Task.WaitAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"School supervisor starting at {TickRate} Hz, up to {MaxSchoolsTotal} schools ({MaxSchools} per player), saves in {Directory}.",
_options.TickRate,
_options.MaxSchoolsTotal,
_options.MaxSchools,
store.DirectoryPath);
await StartWorkersFromDiskAsync().ConfigureAwait(false);
_ = HeartbeatAsync(stoppingToken);
try
{
while (await commands.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false))
{
while (commands.Reader.TryRead(out var command))
{
await DispatchAsync(command).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
await StopAllWorkersAsync(persist: true).ConfigureAwait(false);
logger.LogInformation("School supervisor stopped at tick {Tick}.", CurrentTick);
}
}
private async Task HeartbeatAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(_options.TickInterval);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
Interlocked.Increment(ref _currentTick);
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}
private async Task DispatchAsync(GameCommand command)
{
switch (command)
{
case GameCommand.CreateSchool create:
await HandleCreateAsync(create).ConfigureAwait(false);
break;
case GameCommand.DeleteSchool delete:
await HandleDeleteAsync(delete).ConfigureAwait(false);
break;
case GameCommand.SuggestName suggest:
Complete(suggest.Result, () => SuggestName(suggest.Language));
break;
case GameCommand.OpenSchool open:
HandleOpen(open);
break;
case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId);
Route(close.SchoolId, new WorkerCommand.Close(close.PlayerId));
break;
case GameCommand.SetRunning setRunning:
HandleClockCommand(setRunning.PlayerId, setRunning.NormalizedUserName, new WorkerCommand.SetRunning(setRunning.Running));
break;
case GameCommand.SetSpeed setSpeed:
HandleClockCommand(setSpeed.PlayerId, setSpeed.NormalizedUserName, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
break;
case GameCommand.SkipEmpty skipEmpty:
HandleClockCommand(skipEmpty.PlayerId, skipEmpty.NormalizedUserName, new WorkerCommand.SkipEmpty());
break;
case GameCommand.ReloadSaves reload:
await HandleReloadAsync(reload).ConfigureAwait(false);
break;
case GameCommand.WorkerFailed failed:
HandleWorkerFailed(failed.SchoolId);
break;
case GameCommand.DumpSchool dump:
HandleDump(dump);
break;
case GameCommand.GetPerson getPerson:
HandleGetPerson(getPerson);
break;
case GameCommand.GetPersonLog getLog:
HandleGetPersonLog(getLog);
break;
case GameCommand.HireStaff hire:
HandleStaffing(
hire.SchoolId,
new WorkerCommand.HireStaff(hire.PersonId, hire.Position, hire.Result),
hire.Result);
break;
case GameCommand.AssignSubject assign:
HandleStaffing(
assign.SchoolId,
new WorkerCommand.AssignSubject(assign.PersonId, assign.Subject, assign.Result),
assign.Result);
break;
case GameCommand.UnassignSubject unassign:
HandleStaffing(
unassign.SchoolId,
new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result),
unassign.Result);
break;
case GameCommand.PinLesson pin:
HandleTimetable(
pin.SchoolId,
new WorkerCommand.PinLesson(pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period, pin.Result),
pin.Result);
break;
case GameCommand.UnpinLesson unpin:
HandleTimetable(
unpin.SchoolId,
new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result),
unpin.Result);
break;
case GameCommand.GetDressRules getRules:
HandleDressRules(getRules.SchoolId, new WorkerCommand.GetDressRules(getRules.Result), getRules.Result);
break;
case GameCommand.SetDressRules setRules:
HandleDressRules(
setRules.SchoolId,
new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result),
setRules.Result);
break;
}
}
private void HandleDump(GameCommand.DumpSchool command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.Dump(command.Result)))
{
command.Result.TrySetResult(null);
}
}
private void HandleGetPerson(GameCommand.GetPerson command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.GetPerson(command.PersonId, command.Locale, command.Result)))
{
command.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
}
}
private void HandleGetPersonLog(GameCommand.GetPersonLog command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.GetPersonLog(command.PersonId, command.Locale, command.Query, command.Result)))
{
command.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
}
}
private void HandleDressRules(int schoolId, WorkerCommand command, TaskCompletionSource<DressRulesOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
}
}
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(Staffing.UnknownSchool());
}
}
private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource<TimetableOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
}
}
/// <summary>
/// A school's thread died. Keep a menu card so the player can delete the file instead of
/// opening a school that no longer ticks.
/// </summary>
private void HandleWorkerFailed(int schoolId)
{
if (!_workers.TryGetValue(schoolId, out var worker))
{
return;
}
RememberIncompatible(worker.Snapshot with { Incompatible = true, Running = false });
Untrack(schoolId);
foreach (var client in clients.All)
{
if (client.OpenSchoolId == schoolId)
{
client.OpenSchoolId = null;
SendSchoolGone(client, schoolId);
}
}
logger.LogError("School {SchoolId} stopped after a worker failure; its save file is unchanged.", schoolId);
}
private async Task HandleCreateAsync(GameCommand.CreateSchool command)
{
try
{
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;
}
if (!SchoolNames.TryNormalize(command.Name, out var normalized))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName));
return;
}
if (!GameClock.IsValidStartDate(command.StartDate))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidStartDate));
return;
}
var extras = command.ExtraModIds ?? [];
foreach (var packId in extras)
{
if (!ModContent.IsSafePackId(packId) || !mods.PackExists(packId))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownMod));
return;
}
}
IReadOnlyList<string> packIds;
try
{
packIds = mods.ResolveSelectedPacks(extras);
}
catch (PackDependencyException ex) when (ex.Code == PackDependencyException.MissingCode)
{
command.Result.TrySetResult(new SchoolCreationOutcome(
null,
SchoolCreationError.MissingMod,
ex.MissingPackId));
return;
}
catch (PackDependencyException)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.ModCycle));
return;
}
catch (ContentLoadException)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
if (!mods.PackExists(CatalogLoader.CorePackId))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
DefCatalog catalog;
try
{
catalog = mods.LoadCatalog(packIds);
}
catch (Exception ex) when (ex is ContentLoadException or SchoolContentUnavailableException)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
var countryId = ResolveCountryId(catalog, command.CountryId);
if (countryId is null || !catalog.Countries.TryGetValue(countryId, out var country) || country.Abstract)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownCountry));
return;
}
if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(country.Names, command.NativeLanguage))
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage));
return;
}
var id = _nextId++;
store.WriteNextId(_nextId);
var seed = command.Seed ?? Random.Shared.Next();
var nativeLanguage = NativeLanguages.Pick(country.Names, seed, command.NativeLanguage, rollIfOmitted: true);
var climatePresetId = CountryClimate.Pick(country, seed, rollIfOmitted: true);
var portrait = SwarmUiConfigFile.Clone(command.PortraitSettings ?? swarmSettings.Current);
var worker = SpawnWorker(
id,
normalized,
command.StartDate,
running: true,
ClockSpeed.DefaultIndex,
isNew: true,
packIds,
command.Map,
countryId,
climatePresetId,
nativeLanguage,
seed,
owner: command.Owner,
portraitSettings: portrait);
Track(worker);
worker.Start();
try
{
await worker.Started.ConfigureAwait(false);
}
catch (SchoolContentUnavailableException ex)
{
Untrack(id);
await worker.StopAsync(persist: false).ConfigureAwait(false);
command.Result.TrySetResult(new SchoolCreationOutcome(null, ContentError(ex)));
return;
}
catch
{
Untrack(id);
await worker.StopAsync(persist: false).ConfigureAwait(false);
throw;
}
logger.LogInformation("School {SchoolId} \"{Name}\" created with seed {Seed}.", id, normalized, seed);
command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None));
}
catch (Exception ex)
{
command.Result.TrySetException(ex);
}
}
private async Task HandleDeleteAsync(GameCommand.DeleteSchool command)
{
try
{
if (!_workers.TryGetValue(command.SchoolId, out var worker))
{
if (_incompatible.Remove(command.SchoolId))
{
store.Delete(command.SchoolId);
PublishWorkers();
logger.LogInformation("Incompatible school {SchoolId} deleted.", command.SchoolId);
command.Result.TrySetResult(true);
return;
}
command.Result.TrySetResult(false);
return;
}
Untrack(command.SchoolId);
foreach (var client in clients.All)
{
if (client.OpenSchoolId == command.SchoolId)
{
client.OpenSchoolId = null;
SendSchoolGone(client, command.SchoolId);
}
}
await worker.StopAsync(persist: false).ConfigureAwait(false);
store.Delete(command.SchoolId);
logger.LogInformation("School {SchoolId} deleted.", command.SchoolId);
command.Result.TrySetResult(true);
}
catch (Exception ex)
{
command.Result.TrySetException(ex);
}
}
private async Task HandleReloadAsync(GameCommand.ReloadSaves command)
{
try
{
await StopAllWorkersAsync(persist: true).ConfigureAwait(false);
await StartWorkersFromDiskAsync().ConfigureAwait(false);
command.Result.TrySetResult();
}
catch (Exception ex)
{
command.Result.TrySetException(ex);
}
}
private void HandleOpen(GameCommand.OpenSchool command)
{
var client = clients.Find(command.PlayerId);
if (client is null)
{
return;
}
if (!_workers.TryGetValue(command.SchoolId, out var worker))
{
SendSchoolGone(client, command.SchoolId);
return;
}
client.OpenSchoolId = command.SchoolId;
worker.Post(new WorkerCommand.Open(client));
logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, command.SchoolId);
}
/// <summary>
/// The connection stops receiving clock frames for that school. The calendar keeps running —
/// schools live whether or not somebody is looking at them.
/// </summary>
private void StopWatching(uint playerId, int schoolId)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId == schoolId)
{
client.OpenSchoolId = null;
}
}
private string SuggestName(SchoolNameLanguage language)
{
var taken = SchoolsState.Schools.Select(school => school.Name);
return _names.Next(taken, language);
}
private void Route(int schoolId, WorkerCommand command)
{
if (_workers.TryGetValue(schoolId, out var worker))
{
worker.Post(command);
}
}
private void RouteOpenSchool(uint playerId, WorkerCommand command)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId is not { } schoolId)
{
return;
}
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();
var nextId = Math.Max(store.ReadNextId(), 1);
if (saves.Count > 0)
{
nextId = Math.Max(nextId, saves.Max(save => save.Id) + 1);
}
_nextId = nextId;
var startable = new List<SchoolSave>();
foreach (var save in saves)
{
if (SchoolStore.CanStart(save))
{
startable.Add(save);
}
else
{
RememberIncompatible(FromSave(save));
}
}
if (startable.Count > _options.MaxSchoolsTotal)
{
logger.LogWarning(
"Found {Count} runnable school saves but the limit is {Max}; starting the first {Max}.",
startable.Count,
_options.MaxSchoolsTotal,
_options.MaxSchoolsTotal);
foreach (var extra in startable.Skip(_options.MaxSchoolsTotal))
{
RememberIncompatible(FromSave(extra));
}
startable = [.. startable.Take(_options.MaxSchoolsTotal)];
}
foreach (var save in startable)
{
var worker = SpawnWorker(
save.Id,
save.Name,
save.GameTime,
save.Running,
save.SpeedIndex,
isNew: false,
save.ModIds,
save.Map,
save.CountryId,
save.ClimatePresetId,
save.NativeLanguage,
createSeed: null,
save.Presence,
save.DressRules,
save.Owner,
save.PortraitSettings is null ? null : SwarmUiConfigFile.Clone(save.PortraitSettings));
worker.Start();
try
{
await worker.Started.ConfigureAwait(false);
Track(worker);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"School {SchoolId} \"{Name}\" was not started; the save file is unchanged.",
save.Id,
save.Name);
await worker.StopAsync(persist: false).ConfigureAwait(false);
RememberIncompatible(FromSave(save));
}
}
PublishWorkers();
if (_workers.Count > 0)
{
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
}
}
private async Task StopAllWorkersAsync(bool persist)
{
var stopping = _workers.Values.Select(worker => worker.StopAsync(persist)).ToArray();
_workers.Clear();
_order.Clear();
_incompatible.Clear();
PublishWorkers();
if (stopping.Length > 0)
{
await Task.WhenAll(stopping).ConfigureAwait(false);
}
}
private SchoolWorker SpawnWorker(
int id,
string name,
DateTime time,
bool running,
int speedIndex,
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? map,
string? countryId,
string? climatePresetId,
string? nativeLanguage,
int? createSeed = null,
IReadOnlyList<PresenceSnapshot>? presence = null,
SchoolDressRules? dressRules = null,
string? owner = null,
SwarmUiConfigFile? portraitSettings = null) =>
new(
id,
name,
time,
running,
speedIndex,
isNew,
modIds,
map,
countryId,
climatePresetId,
nativeLanguage,
createSeed,
presence,
dressRules,
owner,
portraitSettings,
_options,
clients,
metrics,
store,
mods,
onFailed: schoolId => commands.Enqueue(new GameCommand.WorkerFailed(schoolId)),
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
/// <summary>
/// Empty request uses the first placeable country (core's Russia). A named id must exist in
/// the catalog already loaded for this pack list — unknown extras were rejected above.
/// </summary>
internal static string? ResolveCountryId(DefCatalog catalog, string? requested)
{
var available = catalog.Countries.Values
.Where(def => !def.Abstract)
.Select(def => def.DefName)
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
if (available.Length == 0)
{
return null;
}
if (string.IsNullOrWhiteSpace(requested))
{
return available[0];
}
return available.Contains(requested, StringComparer.Ordinal) ? requested : null;
}
private void Track(SchoolWorker worker)
{
_workers[worker.Id] = worker;
_order.Add(worker.Id);
PublishWorkers();
}
private void Untrack(int id)
{
_workers.Remove(id);
_order.Remove(id);
PublishWorkers();
}
private void PublishWorkers()
{
var snapshot = new SchoolWorker[_order.Count];
for (var i = 0; i < _order.Count; i++)
{
snapshot[i] = _workers[_order[i]];
}
Volatile.Write(ref _publishedWorkers, snapshot);
metrics.SchoolsChanged(snapshot.Length);
Volatile.Write(
ref _publishedIncompatible,
[.. _incompatible.Values.OrderBy(school => school.Id)]);
}
private void RememberIncompatible(SchoolState school)
{
_incompatible[school.Id] = school with { Incompatible = true, Running = false };
}
private SchoolState FromSave(SchoolSave save) =>
new(
save.Id,
save.Name,
save.GameTime,
Running: false,
(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)
{
try
{
return store.TryReadPeople(schoolId)?.Seed ?? 0;
}
catch (Exception)
{
return 0;
}
}
private static void SendSchoolGone(GameClient client, int schoolId)
{
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId));
client.TrySend(frame.AsMemory(0, length));
}
private static SchoolCreationError ContentError(SchoolContentUnavailableException ex) =>
ex.InnerException switch
{
MapValidationException => SchoolCreationError.InvalidMap,
ContentLoadException => SchoolCreationError.InvalidCatalog,
_ => SchoolCreationError.InvalidCatalog,
};
/// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{
try
{
completion.TrySetResult(work());
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
}
}
internal sealed record PublishedSchoolPeople(
SchoolState School,
Roster? Roster,
ApplicantPool? Applicants,
DefCatalog? Catalog,
Timetable? Timetable,
MapLayout? Map);