Merge main into phase/39-school-owners.
Keep school owners and phase 41 opinions/portraits together; ResetAsync wipes all saves so shared AppHost tests stay isolated.
This commit is contained in:
@@ -20,6 +20,7 @@ internal abstract record GameCommand
|
||||
string? NativeLanguage,
|
||||
int? Seed,
|
||||
string Owner,
|
||||
SwarmUiConfigFile? PortraitSettings,
|
||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||
|
||||
@@ -20,6 +20,7 @@ internal sealed class GameLoopService(
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
SwarmUiSettingsStore swarmSettings,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
@@ -61,6 +62,20 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
@@ -453,7 +468,22 @@ 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, owner: command.Owner);
|
||||
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();
|
||||
|
||||
@@ -670,7 +700,8 @@ internal sealed class GameLoopService(
|
||||
createSeed: null,
|
||||
save.Presence,
|
||||
save.DressRules,
|
||||
save.Owner);
|
||||
save.Owner,
|
||||
save.PortraitSettings);
|
||||
worker.Start();
|
||||
|
||||
try
|
||||
@@ -727,7 +758,8 @@ internal sealed class GameLoopService(
|
||||
int? createSeed = null,
|
||||
IReadOnlyList<PresenceSnapshot>? presence = null,
|
||||
SchoolDressRules? dressRules = null,
|
||||
string? owner = null) =>
|
||||
string? owner = null,
|
||||
SwarmUiConfigFile? portraitSettings = null) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
@@ -744,6 +776,7 @@ internal sealed class GameLoopService(
|
||||
presence,
|
||||
dressRules,
|
||||
owner,
|
||||
portraitSettings,
|
||||
_options,
|
||||
clients,
|
||||
metrics,
|
||||
|
||||
@@ -88,7 +88,8 @@ internal static class PersonCardReader
|
||||
Capacity(person, skills, catalog),
|
||||
person.LockerRoomId is not null
|
||||
|| person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)),
|
||||
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)));
|
||||
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)),
|
||||
Connections: Connections(roster, person, catalog, locale));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
|
||||
@@ -376,8 +377,8 @@ internal static class PersonCardReader
|
||||
}
|
||||
|
||||
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
|
||||
var inParents = family.ParentIds.Contains(person.Id, StringComparer.Ordinal);
|
||||
var inChildren = family.ChildIds.Contains(person.Id, StringComparer.Ordinal);
|
||||
var inParents = InFamily(family.ParentIds, person.Id);
|
||||
var inChildren = InFamily(family.ChildIds, person.Id);
|
||||
return new PersonFamilyResponse(
|
||||
inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [],
|
||||
inParents ? Relatives(family.ChildIds, people, except: person.Id) : [],
|
||||
@@ -385,6 +386,19 @@ internal static class PersonCardReader
|
||||
inParents ? Relatives(family.ParentIds, people, except: person.Id) : []);
|
||||
}
|
||||
|
||||
private static bool InFamily(IReadOnlyList<string> ids, string id)
|
||||
{
|
||||
foreach (var candidate in ids)
|
||||
{
|
||||
if (candidate.Equals(id, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PersonRelResponse> Relatives(
|
||||
IReadOnlyList<string> ids,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
@@ -403,4 +417,98 @@ internal static class PersonCardReader
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static PersonConnectionsResponse Connections(
|
||||
Roster roster,
|
||||
Person person,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
var familyIds = OpinionStore.FamilyMemberIds(roster, person);
|
||||
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
|
||||
var family = FamilyWithOpinions(roster, person, people, catalog, locale);
|
||||
var others = new List<PersonOpinionLinkResponse>();
|
||||
foreach (var (targetId, value) in person.Opinions)
|
||||
{
|
||||
if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
others.Add(Link(target, value, catalog, locale));
|
||||
}
|
||||
|
||||
others.Sort((left, right) =>
|
||||
{
|
||||
var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion));
|
||||
return byAbs != 0
|
||||
? byAbs
|
||||
: string.Compare(left.FullName, right.FullName, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5;
|
||||
var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray();
|
||||
var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray();
|
||||
return new PersonConnectionsResponse(family, friends, enemies, others);
|
||||
}
|
||||
|
||||
private static PersonFamilyResponse FamilyWithOpinions(
|
||||
Roster roster,
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
|
||||
if (family is null)
|
||||
{
|
||||
return new PersonFamilyResponse([], [], [], []);
|
||||
}
|
||||
|
||||
var inParents = InFamily(family.ParentIds, person.Id);
|
||||
var inChildren = InFamily(family.ChildIds, person.Id);
|
||||
return new PersonFamilyResponse(
|
||||
inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [],
|
||||
inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
|
||||
inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
|
||||
inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PersonRelResponse> RelativesWithOpinions(
|
||||
IReadOnlyList<string> ids,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
Person person,
|
||||
string except,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
var rows = new List<PersonRelResponse>();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int? opinion = null;
|
||||
string? label = null;
|
||||
if (person.Opinions.TryGetValue(id, out var value))
|
||||
{
|
||||
opinion = value;
|
||||
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
|
||||
}
|
||||
|
||||
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) =>
|
||||
new(
|
||||
target.Id,
|
||||
target.Name.Full,
|
||||
target.Female,
|
||||
opinion,
|
||||
catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ internal sealed class PortraitService(
|
||||
SchoolStore store,
|
||||
SwarmUiClient swarm,
|
||||
SwarmUiSettingsStore settingsStore,
|
||||
GameLoopService loop,
|
||||
GameCommandQueue commands,
|
||||
ILogger<PortraitService> logger)
|
||||
{
|
||||
@@ -74,7 +75,7 @@ internal sealed class PortraitService(
|
||||
return PortraitPromptBuildResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
|
||||
var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
|
||||
return PortraitPromptBuildResult.Succeeded(
|
||||
kind,
|
||||
@@ -122,7 +123,7 @@ internal sealed class PortraitService(
|
||||
return PortraitGenerationResult.UnknownSchool;
|
||||
}
|
||||
|
||||
var profile = settingsStore.Resolve(outcome.Card.Age, kind);
|
||||
var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
|
||||
|
||||
try
|
||||
@@ -155,6 +156,9 @@ internal sealed class PortraitService(
|
||||
}
|
||||
}
|
||||
|
||||
private SwarmUiConfigFile ConfigFor(int schoolId) =>
|
||||
loop.PortraitSettingsOf(schoolId) ?? settingsStore.Current;
|
||||
|
||||
private async Task<PersonCardResult> LookupPersonAsync(
|
||||
int schoolId,
|
||||
string personId,
|
||||
|
||||
@@ -38,6 +38,9 @@ internal sealed class SchoolSave
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||
@@ -176,6 +179,7 @@ internal sealed class SchoolStore
|
||||
Presence = save.Presence,
|
||||
DressRules = save.DressRules,
|
||||
Owner = save.Owner,
|
||||
PortraitSettings = save.PortraitSettings,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -39,6 +39,7 @@ internal sealed class SchoolWorker
|
||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||
private readonly SchoolDressRules? _savedDressRules;
|
||||
private readonly string? _owner;
|
||||
private readonly SwarmUiConfigFile? _portraitSettings;
|
||||
private readonly Action<int> _onFailed;
|
||||
|
||||
private readonly int _id;
|
||||
@@ -76,6 +77,7 @@ internal sealed class SchoolWorker
|
||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||
SchoolDressRules? savedDressRules,
|
||||
string? owner,
|
||||
SwarmUiConfigFile? portraitSettings,
|
||||
SimulationOptions options,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
@@ -99,6 +101,7 @@ internal sealed class SchoolWorker
|
||||
_savedPresence = savedPresence;
|
||||
_savedDressRules = savedDressRules;
|
||||
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
|
||||
_portraitSettings = portraitSettings;
|
||||
_options = options;
|
||||
_clients = clients;
|
||||
_metrics = metrics;
|
||||
@@ -116,6 +119,9 @@ internal sealed class SchoolWorker
|
||||
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
||||
public SchoolState Snapshot => Volatile.Read(ref _snapshot);
|
||||
|
||||
/// <summary>Presets copied at create. Null on older saves — generation then uses the global template.</summary>
|
||||
public SwarmUiConfigFile? PortraitSettings => _portraitSettings;
|
||||
|
||||
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
||||
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
||||
|
||||
@@ -983,6 +989,12 @@ internal sealed class SchoolWorker
|
||||
RequireKnownApparel(catalog, roster, applicants);
|
||||
}
|
||||
|
||||
if (OpinionGenerator.NeedsFamilyOpinions(roster))
|
||||
{
|
||||
roster = OpinionGenerator.SeedFamily(catalog, roster);
|
||||
generated = true;
|
||||
}
|
||||
|
||||
roster = LockerAssigner.Apply(catalog, map, roster);
|
||||
|
||||
if (!RosterFit.Matches(roster, demand))
|
||||
@@ -1069,6 +1081,7 @@ internal sealed class SchoolWorker
|
||||
Presence = school.CapturePresence(),
|
||||
DressRules = school.DressRules,
|
||||
Owner = _owner,
|
||||
PortraitSettings = _portraitSettings,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -233,6 +233,33 @@ internal sealed class SwarmUiConfigFile
|
||||
Presets = [SwarmUiPresetDefinition.CreateDefault(), SwarmUiPresetDefinition.CreateChild()],
|
||||
AgeRules = [new SwarmUiAgeRule { MinAge = 6, MaxAge = 11, PresetId = "child" }],
|
||||
};
|
||||
|
||||
public static SwarmUiConfigFile Clone(SwarmUiConfigFile source)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(source, CloneJson);
|
||||
var copy = JsonSerializer.Deserialize<SwarmUiConfigFile>(json, CloneJson) ?? CreateDefault();
|
||||
copy.Model = null;
|
||||
copy.Steps = null;
|
||||
copy.CfgScale = null;
|
||||
copy.ClipSkip = null;
|
||||
copy.Sampler = null;
|
||||
copy.Scheduler = null;
|
||||
copy.Seed = null;
|
||||
copy.Positive = null;
|
||||
copy.Negative = null;
|
||||
copy.Avatar = null;
|
||||
copy.Custom = null;
|
||||
copy.FullBody = null;
|
||||
copy.NormalizeAfterLoad();
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CloneJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class SwarmUiPresetDefinition
|
||||
|
||||
Reference in New Issue
Block a user