Add scene fragments to portrait prompts after clothing.

Thing, room, yard and climate precipitation can contribute a short prompt when they are in frame, with a budget so chairs stay silent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 23:35:17 +03:00
co-authored by Cursor
parent f046f145ec
commit 3c682a2b74
26 changed files with 1020 additions and 26 deletions
+7
View File
@@ -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.");
+9
View File
@@ -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)
+3
View File
@@ -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; }
}
+61
View File
@@ -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.");
}
}
}
}