Merge branch 'phase/66-scene-prompt'
This commit is contained in:
@@ -454,8 +454,14 @@ public sealed class CatalogLoader
|
||||
ValidateColor(color);
|
||||
}
|
||||
|
||||
foreach (var thing in catalog.Things.Values)
|
||||
{
|
||||
PromptContributionValidator.Validate($"ThingDef '{thing.DefName}'", thing.Prompt);
|
||||
}
|
||||
|
||||
foreach (var room in catalog.Rooms.Values)
|
||||
{
|
||||
PromptContributionValidator.Validate($"RoomDef '{room.DefName}'", room.Prompt);
|
||||
foreach (var slot in room.Slots)
|
||||
{
|
||||
if (!catalog.Things.ContainsKey(slot.Thing))
|
||||
@@ -532,6 +538,7 @@ public sealed class CatalogLoader
|
||||
|
||||
foreach (var territory in catalog.Territories.Values)
|
||||
{
|
||||
PromptContributionValidator.Validate($"TerritoryDef '{territory.DefName}'", territory.Prompt);
|
||||
if (!territory.Abstract && territory.TravelMinutes <= 0)
|
||||
{
|
||||
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
|
||||
|
||||
@@ -120,6 +120,9 @@ public sealed class ThingDef : Def
|
||||
/// are granted per subject instead). Apparel ignores this and fills layers.
|
||||
/// </summary>
|
||||
public float CarryChance { get; init; }
|
||||
|
||||
/// <summary>Portrait fragment when this thing is in the person's frame. Null or empty — silent.</summary>
|
||||
public PromptContribution? Prompt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PositionDef : Def;
|
||||
@@ -169,6 +172,9 @@ public sealed class RoomDef : Def
|
||||
/// When set, pupil lockers in this room go to students of that sex (<c>male</c> / <c>female</c>).
|
||||
/// </summary>
|
||||
public string? LockerSex { get; init; }
|
||||
|
||||
/// <summary>Portrait fragment when the person is in a room of this def. Null or empty — silent.</summary>
|
||||
public PromptContribution? Prompt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class BuildingDef : Def;
|
||||
@@ -179,4 +185,7 @@ public sealed class TerritoryDef : Def
|
||||
{
|
||||
/// <summary>Game minutes spent occupying the yard when walking through it.</summary>
|
||||
public float TravelMinutes { get; init; }
|
||||
|
||||
/// <summary>Portrait fragment when the person is in this yard. Null or empty — silent.</summary>
|
||||
public PromptContribution? Prompt { get; init; }
|
||||
}
|
||||
|
||||
@@ -883,6 +883,8 @@ internal static class PeopleDefValidator
|
||||
{
|
||||
throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' insulationPerC cannot be negative.");
|
||||
}
|
||||
|
||||
PromptContributionValidator.ValidateClimate($"ClimatePresetDef '{preset.DefName}'", preset.Prompt);
|
||||
}
|
||||
|
||||
private static void ValidateNameSet(NameSetDef names, DefCatalog catalog, string countryDefName)
|
||||
|
||||
@@ -801,4 +801,7 @@ public sealed class ClimatePresetDef : Def
|
||||
|
||||
/// <summary>How many °C of protection one insulation point is worth.</summary>
|
||||
public float InsulationPerC { get; init; } = 1f;
|
||||
|
||||
/// <summary>Outdoor portrait fragments for clear / rain / snow. Indoor portraits ignore this.</summary>
|
||||
public ClimatePromptSet? Prompt { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>One LoRA or embedding a scene def may add to a portrait. Empty name is ignored.</summary>
|
||||
public sealed class PromptLoraRef
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public float Weight { get; init; } = 1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional portrait-prompt fragment on a thing, room, yard or precipitation. Missing or empty
|
||||
/// means the def is silent — a chair does not name itself in the prompt.
|
||||
/// </summary>
|
||||
public sealed class PromptContribution
|
||||
{
|
||||
public string Positive { get; init; } = "";
|
||||
|
||||
public string Negative { get; init; } = "";
|
||||
|
||||
public float Weight { get; init; } = 1f;
|
||||
|
||||
public IReadOnlyList<PromptLoraRef> PositiveLoras { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<PromptLoraRef> NegativeLoras { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<PromptLoraRef> PositiveEmbeddings { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<PromptLoraRef> NegativeEmbeddings { get; init; } = [];
|
||||
|
||||
public bool IsSilent =>
|
||||
string.IsNullOrWhiteSpace(Positive)
|
||||
&& string.IsNullOrWhiteSpace(Negative)
|
||||
&& !HasNamed(PositiveLoras)
|
||||
&& !HasNamed(NegativeLoras)
|
||||
&& !HasNamed(PositiveEmbeddings)
|
||||
&& !HasNamed(NegativeEmbeddings);
|
||||
|
||||
private static bool HasNamed(IReadOnlyList<PromptLoraRef> entries)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entry.Name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clear / rain / snow fragments on a climate preset. Outdoor portraits pick one.</summary>
|
||||
public sealed class ClimatePromptSet
|
||||
{
|
||||
public PromptContribution? Clear { get; init; }
|
||||
|
||||
public PromptContribution? Rain { get; init; }
|
||||
|
||||
public PromptContribution? Snow { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
internal static class PromptContributionValidator
|
||||
{
|
||||
public const int MaxFragmentLength = 120;
|
||||
|
||||
public static void Validate(string owner, PromptContribution? contribution)
|
||||
{
|
||||
if (contribution is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateFragment(owner, "positive", contribution.Positive);
|
||||
ValidateFragment(owner, "negative", contribution.Negative);
|
||||
|
||||
if (!float.IsFinite(contribution.Weight) || contribution.Weight < 0f)
|
||||
{
|
||||
throw new ContentLoadException($"{owner} prompt weight must be a finite number ≥ 0.");
|
||||
}
|
||||
|
||||
ValidateLoras(owner, "positive LoRA", contribution.PositiveLoras);
|
||||
ValidateLoras(owner, "negative LoRA", contribution.NegativeLoras);
|
||||
ValidateLoras(owner, "positive embedding", contribution.PositiveEmbeddings);
|
||||
ValidateLoras(owner, "negative embedding", contribution.NegativeEmbeddings);
|
||||
}
|
||||
|
||||
public static void ValidateClimate(string owner, ClimatePromptSet? prompts)
|
||||
{
|
||||
if (prompts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Validate($"{owner} clear", prompts.Clear);
|
||||
Validate($"{owner} rain", prompts.Rain);
|
||||
Validate($"{owner} snow", prompts.Snow);
|
||||
}
|
||||
|
||||
private static void ValidateFragment(string owner, string side, string value)
|
||||
{
|
||||
if (value.Length > MaxFragmentLength)
|
||||
{
|
||||
throw new ContentLoadException(
|
||||
$"{owner} prompt {side} must be at most {MaxFragmentLength} characters.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLoras(string owner, string side, IReadOnlyList<PromptLoraRef> entries)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Name))
|
||||
{
|
||||
throw new ContentLoadException($"{owner} prompt {side} is missing a name.");
|
||||
}
|
||||
|
||||
if (!float.IsFinite(entry.Weight))
|
||||
{
|
||||
throw new ContentLoadException($"{owner} prompt {side} '{entry.Name}' weight must be finite.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ internal abstract record GameCommand
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : GameCommand;
|
||||
|
||||
/// <summary>Person card plus scene fragments for a portrait. Completes on that school's worker.</summary>
|
||||
internal sealed record GetPortraitBuildInput(
|
||||
int SchoolId,
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PortraitBuildInputResult> Result) : GameCommand;
|
||||
|
||||
/// <summary>Today's history for one person. Completes on that school's worker; not a snapshot.</summary>
|
||||
internal sealed record GetPersonLog(
|
||||
int SchoolId,
|
||||
|
||||
@@ -253,6 +253,10 @@ internal sealed class GameLoopService(
|
||||
HandleGetPerson(getPerson);
|
||||
break;
|
||||
|
||||
case GameCommand.GetPortraitBuildInput getBuild:
|
||||
HandleGetPortraitBuildInput(getBuild);
|
||||
break;
|
||||
|
||||
case GameCommand.GetPersonLog getLog:
|
||||
HandleGetPersonLog(getLog);
|
||||
break;
|
||||
@@ -334,6 +338,16 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleGetPortraitBuildInput(GameCommand.GetPortraitBuildInput command)
|
||||
{
|
||||
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|
||||
|| !worker.Post(new WorkerCommand.GetPortraitBuildInput(command.PersonId, command.Locale, command.Result)))
|
||||
{
|
||||
command.Result.TrySetResult(
|
||||
new PortraitBuildInputResult(null, PortraitScene.Empty, PersonLookupError.UnknownSchool));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleGetPersonLog(GameCommand.GetPersonLog command)
|
||||
{
|
||||
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Ai;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Api;
|
||||
@@ -20,6 +21,9 @@ internal static partial class PersonCardReader
|
||||
private static readonly QueryDescription IdentityAndActivity =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
|
||||
|
||||
private static readonly QueryDescription IdentityAndPresence =
|
||||
new QueryDescription().WithAll<PersonIdentity, Presence>();
|
||||
|
||||
public static PersonCardResponse? Read(School school, string personId, string locale)
|
||||
{
|
||||
var roster = school.Roster;
|
||||
@@ -136,4 +140,18 @@ internal static partial class PersonCardReader
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
public static string? NodeId(School school, string personId)
|
||||
{
|
||||
string? found = null;
|
||||
var query = IdentityAndPresence;
|
||||
school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence) =>
|
||||
{
|
||||
if (identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
found = presence.NodeId;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ internal static class PortraitPromptBuilder
|
||||
PersonCardResponse card,
|
||||
SwarmUiResolvedProfile profile,
|
||||
PortraitKind kind,
|
||||
string? promptExtra = null)
|
||||
string? promptExtra = null,
|
||||
PortraitScene? scene = null)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
Add(parts, profile.ModelPositive);
|
||||
@@ -29,16 +30,36 @@ internal static class PortraitPromptBuilder
|
||||
Add(parts, DescribeAppearance(card));
|
||||
Add(parts, DescribeClothing(card));
|
||||
|
||||
scene ??= PortraitScene.Empty;
|
||||
foreach (var fragment in scene.PositiveFragments)
|
||||
{
|
||||
Add(parts, fragment);
|
||||
}
|
||||
|
||||
var positive = SwarmUiLoraFormatter.AppendEmbedTags(
|
||||
string.Join(", ", parts),
|
||||
profile.PositiveEmbeddings);
|
||||
SwarmUiLoraFormatter.Concat(profile.PositiveEmbeddings, scene.PositiveEmbeddings));
|
||||
var negativeParts = new List<string>();
|
||||
Add(negativeParts, profile.Negative);
|
||||
foreach (var fragment in scene.NegativeFragments)
|
||||
{
|
||||
Add(negativeParts, fragment);
|
||||
}
|
||||
|
||||
var negative = SwarmUiLoraFormatter.AppendEmbedTags(
|
||||
profile.Negative.Trim(),
|
||||
profile.NegativeEmbeddings);
|
||||
string.Join(", ", negativeParts),
|
||||
SwarmUiLoraFormatter.Concat(profile.NegativeEmbeddings, scene.NegativeEmbeddings));
|
||||
|
||||
return (positive, negative);
|
||||
}
|
||||
|
||||
public static SwarmUiResolvedProfile ApplyScene(SwarmUiResolvedProfile profile, PortraitScene scene) =>
|
||||
profile with
|
||||
{
|
||||
PositiveLoras = SwarmUiLoraFormatter.Concat(profile.PositiveLoras, scene.PositiveLoras),
|
||||
NegativeLoras = SwarmUiLoraFormatter.Concat(profile.NegativeLoras, scene.NegativeLoras),
|
||||
};
|
||||
|
||||
private static void Add(List<string> parts, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using HSchool.Server.Api;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Budgeted scene fragments collected on the school's worker for one portrait.</summary>
|
||||
internal sealed record PortraitScene(
|
||||
IReadOnlyList<string> PositiveFragments,
|
||||
IReadOnlyList<string> NegativeFragments,
|
||||
IReadOnlyList<SwarmUiLoraEntry> PositiveLoras,
|
||||
IReadOnlyList<SwarmUiLoraEntry> NegativeLoras,
|
||||
IReadOnlyList<SwarmUiLoraEntry> PositiveEmbeddings,
|
||||
IReadOnlyList<SwarmUiLoraEntry> NegativeEmbeddings)
|
||||
{
|
||||
public static PortraitScene Empty { get; } = new([], [], [], [], [], []);
|
||||
}
|
||||
|
||||
internal sealed record PortraitBuildInputResult(
|
||||
PersonCardResponse? Card,
|
||||
PortraitScene Scene,
|
||||
PersonLookupError Error);
|
||||
@@ -0,0 +1,287 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Asks the person's room, fill, worn/held and outdoor weather for prompt fragments.
|
||||
/// Runs on the school worker; Simulation does not know about Swarm.
|
||||
/// </summary>
|
||||
internal static class PortraitSceneCollector
|
||||
{
|
||||
public static PortraitScene Collect(School school, PersonCardResponse card, SimulationOptions options)
|
||||
{
|
||||
if (school.Catalog is null)
|
||||
{
|
||||
return PortraitScene.Empty;
|
||||
}
|
||||
|
||||
return Collect(
|
||||
school.Catalog,
|
||||
school.Map,
|
||||
PersonCardReader.NodeId(school, card.Id),
|
||||
school.Weather.Precipitation,
|
||||
school.ClimatePresetId,
|
||||
card.Worn.Select(item => item.DefName),
|
||||
card.Carried.Select(item => item.DefName),
|
||||
options);
|
||||
}
|
||||
|
||||
public static PortraitScene Collect(
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
string? nodeId,
|
||||
Precipitation precipitation,
|
||||
string? climatePresetId,
|
||||
IEnumerable<string> wornDefs,
|
||||
IEnumerable<string> heldDefs,
|
||||
SimulationOptions options)
|
||||
{
|
||||
var fragmentLimit = Math.Max(0, options.PortraitSceneFragmentLimit);
|
||||
var loraLimit = Math.Max(0, options.PortraitSceneLoraLimit);
|
||||
var embeddingLimit = Math.Max(0, options.PortraitSceneEmbeddingLimit);
|
||||
|
||||
var candidates = new Dictionary<string, PromptContribution>(StringComparer.Ordinal);
|
||||
TryAddPlace(candidates, catalog, map, nodeId);
|
||||
TryAddFill(candidates, catalog, map, nodeId);
|
||||
TryAddThings(candidates, catalog, wornDefs);
|
||||
TryAddThings(candidates, catalog, heldDefs);
|
||||
TryAddWeather(candidates, catalog, map, nodeId, precipitation, climatePresetId);
|
||||
|
||||
var ranked = candidates
|
||||
.OrderByDescending(pair => pair.Value.Weight)
|
||||
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var positives = new List<string>();
|
||||
var negatives = new List<string>();
|
||||
foreach (var pair in ranked)
|
||||
{
|
||||
TryAddFragment(positives, negatives, positives, fragmentLimit, pair.Value.Positive);
|
||||
TryAddFragment(positives, negatives, negatives, fragmentLimit, pair.Value.Negative);
|
||||
}
|
||||
|
||||
var loras = TakeLoras(ranked, loraLimit);
|
||||
var embeddings = TakeEmbeddings(ranked, embeddingLimit);
|
||||
return new PortraitScene(
|
||||
positives,
|
||||
negatives,
|
||||
loras.Positive,
|
||||
loras.Negative,
|
||||
embeddings.Positive,
|
||||
embeddings.Negative);
|
||||
}
|
||||
|
||||
private static void TryAddPlace(
|
||||
Dictionary<string, PromptContribution> candidates,
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
string? nodeId)
|
||||
{
|
||||
if (nodeId is null || map is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defName = map.NodeDef(nodeId);
|
||||
if (defName is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (catalog.Rooms.TryGetValue(defName, out var room))
|
||||
{
|
||||
TryAdd(candidates, room.DefName, room.Prompt);
|
||||
if (room.Homeroom && !string.IsNullOrWhiteSpace(room.SeatThing))
|
||||
{
|
||||
TryAddThing(candidates, catalog, room.SeatThing);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (catalog.Territories.TryGetValue(defName, out var territory))
|
||||
{
|
||||
TryAdd(candidates, territory.DefName, territory.Prompt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryAddFill(
|
||||
Dictionary<string, PromptContribution> candidates,
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
string? nodeId)
|
||||
{
|
||||
if (nodeId is null || map is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var room in map.Rooms)
|
||||
{
|
||||
if (!room.Id.Equals(nodeId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var fill in room.Slots)
|
||||
{
|
||||
TryAddThing(candidates, catalog, fill.Thing);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryAddWeather(
|
||||
Dictionary<string, PromptContribution> candidates,
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
string? nodeId,
|
||||
Precipitation precipitation,
|
||||
string? climatePresetId)
|
||||
{
|
||||
if (nodeId is null
|
||||
|| map is null
|
||||
|| string.IsNullOrWhiteSpace(climatePresetId)
|
||||
|| !PlaceClimate.IsOutdoor(catalog, map, nodeId)
|
||||
|| !catalog.ClimatePresets.TryGetValue(climatePresetId, out var preset))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var contribution = precipitation switch
|
||||
{
|
||||
Precipitation.Rain => preset.Prompt?.Rain,
|
||||
Precipitation.Snow => preset.Prompt?.Snow,
|
||||
_ => preset.Prompt?.Clear,
|
||||
};
|
||||
|
||||
TryAdd(candidates, $"{preset.DefName}:{precipitation}", contribution);
|
||||
}
|
||||
|
||||
private static void TryAddThings(
|
||||
Dictionary<string, PromptContribution> candidates,
|
||||
DefCatalog catalog,
|
||||
IEnumerable<string> defNames)
|
||||
{
|
||||
foreach (var defName in defNames)
|
||||
{
|
||||
TryAddThing(candidates, catalog, defName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryAddThing(
|
||||
Dictionary<string, PromptContribution> candidates,
|
||||
DefCatalog catalog,
|
||||
string defName)
|
||||
{
|
||||
if (catalog.Things.TryGetValue(defName, out var thing))
|
||||
{
|
||||
TryAdd(candidates, thing.DefName, thing.Prompt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryAdd(
|
||||
Dictionary<string, PromptContribution> candidates,
|
||||
string defName,
|
||||
PromptContribution? contribution)
|
||||
{
|
||||
if (contribution is null || contribution.IsSilent || candidates.ContainsKey(defName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
candidates[defName] = contribution;
|
||||
}
|
||||
|
||||
private static void TryAddFragment(
|
||||
List<string> positives,
|
||||
List<string> negatives,
|
||||
List<string> target,
|
||||
int limit,
|
||||
string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || positives.Count + negatives.Count >= limit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (positives.Contains(trimmed, StringComparer.Ordinal)
|
||||
|| negatives.Contains(trimmed, StringComparer.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
target.Add(trimmed);
|
||||
}
|
||||
|
||||
private static (List<SwarmUiLoraEntry> Positive, List<SwarmUiLoraEntry> Negative) TakeLoras(
|
||||
List<KeyValuePair<string, PromptContribution>> ranked,
|
||||
int limit) =>
|
||||
TakeEntries(
|
||||
ranked,
|
||||
contribution => contribution.PositiveLoras,
|
||||
contribution => contribution.NegativeLoras,
|
||||
limit);
|
||||
|
||||
private static (List<SwarmUiLoraEntry> Positive, List<SwarmUiLoraEntry> Negative) TakeEmbeddings(
|
||||
List<KeyValuePair<string, PromptContribution>> ranked,
|
||||
int limit) =>
|
||||
TakeEntries(
|
||||
ranked,
|
||||
contribution => contribution.PositiveEmbeddings,
|
||||
contribution => contribution.NegativeEmbeddings,
|
||||
limit);
|
||||
|
||||
private static (List<SwarmUiLoraEntry> Positive, List<SwarmUiLoraEntry> Negative) TakeEntries(
|
||||
List<KeyValuePair<string, PromptContribution>> ranked,
|
||||
Func<PromptContribution, IReadOnlyList<PromptLoraRef>> positives,
|
||||
Func<PromptContribution, IReadOnlyList<PromptLoraRef>> negatives,
|
||||
int limit)
|
||||
{
|
||||
var positive = new List<SwarmUiLoraEntry>();
|
||||
var negative = new List<SwarmUiLoraEntry>();
|
||||
if (limit <= 0)
|
||||
{
|
||||
return (positive, negative);
|
||||
}
|
||||
|
||||
var taken = 0;
|
||||
foreach (var pair in ranked)
|
||||
{
|
||||
taken += Append(positive, positives(pair.Value), limit - taken);
|
||||
if (taken >= limit)
|
||||
{
|
||||
return (positive, negative);
|
||||
}
|
||||
|
||||
taken += Append(negative, negatives(pair.Value), limit - taken);
|
||||
if (taken >= limit)
|
||||
{
|
||||
return (positive, negative);
|
||||
}
|
||||
}
|
||||
|
||||
return (positive, negative);
|
||||
}
|
||||
|
||||
private static int Append(List<SwarmUiLoraEntry> target, IReadOnlyList<PromptLoraRef> source, int remaining)
|
||||
{
|
||||
var added = 0;
|
||||
foreach (var entry in source)
|
||||
{
|
||||
if (added >= remaining || string.IsNullOrWhiteSpace(entry.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
target.Add(new SwarmUiLoraEntry { Name = entry.Name.Trim(), Weight = entry.Weight });
|
||||
added++;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ internal sealed class PortraitService(
|
||||
return PortraitPromptBuildResult.InvalidPrompt;
|
||||
}
|
||||
|
||||
var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
|
||||
var outcome = await LookupBuildInputAsync(schoolId, personId, cancellationToken);
|
||||
if (outcome.Error == PersonLookupError.UnknownPerson)
|
||||
{
|
||||
return PortraitPromptBuildResult.UnknownPerson;
|
||||
@@ -76,7 +76,12 @@ internal sealed class PortraitService(
|
||||
}
|
||||
|
||||
var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, resolved);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(
|
||||
outcome.Card,
|
||||
profile,
|
||||
kind,
|
||||
resolved,
|
||||
outcome.Scene);
|
||||
return PortraitPromptBuildResult.Succeeded(
|
||||
kind,
|
||||
positive,
|
||||
@@ -112,7 +117,7 @@ internal sealed class PortraitService(
|
||||
}
|
||||
}
|
||||
|
||||
var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
|
||||
var outcome = await LookupBuildInputAsync(schoolId, personId, cancellationToken);
|
||||
if (outcome.Error == PersonLookupError.UnknownPerson)
|
||||
{
|
||||
return PortraitGenerationResult.UnknownPerson;
|
||||
@@ -124,11 +129,20 @@ internal sealed class PortraitService(
|
||||
}
|
||||
|
||||
var profile = ConfigFor(schoolId).Resolve(outcome.Card.Age, kind);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, profile, kind, promptExtra);
|
||||
var (positive, negative) = PortraitPromptBuilder.Build(
|
||||
outcome.Card,
|
||||
profile,
|
||||
kind,
|
||||
promptExtra,
|
||||
outcome.Scene);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = await swarm.GenerateAsync(positive, negative, profile, cancellationToken);
|
||||
var bytes = await swarm.GenerateAsync(
|
||||
positive,
|
||||
negative,
|
||||
PortraitPromptBuilder.ApplyScene(profile, outcome.Scene),
|
||||
cancellationToken);
|
||||
store.SavePortrait(schoolId, personId, kind, bytes, kind == PortraitKind.Custom ? promptExtra : null);
|
||||
var flags = Flags(schoolId, personId);
|
||||
var savedPrompt = kind == PortraitKind.Custom ? promptExtra : store.TryReadCustomPortraitPrompt(schoolId, personId);
|
||||
@@ -159,6 +173,20 @@ internal sealed class PortraitService(
|
||||
private SwarmUiConfigFile ConfigFor(int schoolId) =>
|
||||
loop.PortraitSettingsOf(schoolId) ?? settingsStore.Current;
|
||||
|
||||
private async Task<PortraitBuildInputResult> LookupBuildInputAsync(
|
||||
int schoolId,
|
||||
string personId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new GameCommand.GetPortraitBuildInput(
|
||||
schoolId,
|
||||
personId,
|
||||
PromptLocale,
|
||||
NewCompletion<PortraitBuildInputResult>());
|
||||
commands.Enqueue(command);
|
||||
return await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PersonCardResult> LookupPersonAsync(
|
||||
int schoolId,
|
||||
string personId,
|
||||
|
||||
@@ -113,6 +113,10 @@ internal sealed partial class SchoolWorker
|
||||
: new PersonCardResult(card, PersonLookupError.None));
|
||||
break;
|
||||
|
||||
case WorkerCommand.GetPortraitBuildInput getBuild:
|
||||
getBuild.Result.TrySetResult(ReadPortraitBuildInput(school, getBuild.PersonId, getBuild.Locale));
|
||||
break;
|
||||
|
||||
case WorkerCommand.GetPersonLog getLog:
|
||||
var log = PersonLogReader.Read(school, getLog.PersonId, getLog.Query, getLog.Locale);
|
||||
getLog.Result.TrySetResult(
|
||||
@@ -229,6 +233,10 @@ internal sealed partial class SchoolWorker
|
||||
case WorkerCommand.GetPerson getPerson:
|
||||
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.GetPortraitBuildInput getBuild:
|
||||
getBuild.Result.TrySetResult(
|
||||
new PortraitBuildInputResult(null, PortraitScene.Empty, PersonLookupError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.GetPersonLog getLog:
|
||||
getLog.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
|
||||
break;
|
||||
@@ -278,6 +286,9 @@ internal sealed partial class SchoolWorker
|
||||
case WorkerCommand.GetPerson getPerson:
|
||||
getPerson.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.GetPortraitBuildInput getBuild:
|
||||
getBuild.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.GetPersonLog getLog:
|
||||
getLog.Result.TrySetException(exception);
|
||||
break;
|
||||
@@ -343,6 +354,20 @@ internal sealed partial class SchoolWorker
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private PortraitBuildInputResult ReadPortraitBuildInput(School school, string personId, string locale)
|
||||
{
|
||||
var card = PersonCardReader.Read(school, personId, locale);
|
||||
if (card is null)
|
||||
{
|
||||
return new PortraitBuildInputResult(null, PortraitScene.Empty, PersonLookupError.UnknownPerson);
|
||||
}
|
||||
|
||||
return new PortraitBuildInputResult(
|
||||
card,
|
||||
PortraitSceneCollector.Collect(school, card, _options),
|
||||
PersonLookupError.None);
|
||||
}
|
||||
|
||||
private TimetableOutcome ApplyPin(
|
||||
School school,
|
||||
string classId,
|
||||
|
||||
@@ -39,6 +39,11 @@ internal abstract record WorkerCommand
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record GetPortraitBuildInput(
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PortraitBuildInputResult> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record GetPersonLog(
|
||||
string PersonId,
|
||||
string Locale,
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
"SaveIntervalSeconds": 30,
|
||||
"MonthlyPayrollCap": 100000,
|
||||
"SchoolWeekDays": 5,
|
||||
"MaxDecisionsPerTick": 64
|
||||
"MaxDecisionsPerTick": 64,
|
||||
"PortraitSceneFragmentLimit": 6,
|
||||
"PortraitSceneLoraLimit": 2,
|
||||
"PortraitSceneEmbeddingLimit": 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@
|
||||
"comfortC": 21,
|
||||
"comfortHalfWidthC": 3,
|
||||
"insulationPerC": 1,
|
||||
"prompt": {
|
||||
"snow": { "positive": "falling snow, winter overcast schoolyard", "weight": 1 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,4 +9,7 @@
|
||||
"comfortC": 21,
|
||||
"comfortHalfWidthC": 3,
|
||||
"insulationPerC": 1,
|
||||
"prompt": {
|
||||
"snow": { "positive": "falling snow, winter overcast schoolyard", "weight": 1 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"defaultSeats": 16,
|
||||
"works": ["TeachLesson"],
|
||||
"travelMinutes": 0.5,
|
||||
"prompt": { "positive": "a classroom blackboard with chalk notes", "weight": 1 },
|
||||
},
|
||||
{
|
||||
"defName": "Library",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
[
|
||||
{ "defName": "Blackboard" },
|
||||
{
|
||||
"defName": "Blackboard",
|
||||
"prompt": { "positive": "a classroom blackboard with chalk notes", "weight": 1 },
|
||||
},
|
||||
{ "defName": "StudentDesk", "parent": "Desk", "actions": ["Sit"], "pupilSlots": 1 },
|
||||
{ "defName": "Bookshelf" },
|
||||
{ "defName": "DiningTable" },
|
||||
|
||||
@@ -64,6 +64,15 @@ public sealed class SimulationOptions
|
||||
/// </summary>
|
||||
public int MaxDecisionsPerTick { get; set; } = 64;
|
||||
|
||||
/// <summary>How many scene text fragments a portrait may take. Silent defs do not count.</summary>
|
||||
public int PortraitSceneFragmentLimit { get; set; } = 6;
|
||||
|
||||
/// <summary>How many scene LoRAs a portrait may add beyond model / preset / shot layers.</summary>
|
||||
public int PortraitSceneLoraLimit { get; set; } = 2;
|
||||
|
||||
/// <summary>How many scene embeddings a portrait may add beyond model / preset / shot layers.</summary>
|
||||
public int PortraitSceneEmbeddingLimit { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Unclosed warning/error notices kept in the save. The next one is not posted.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user