Merge branch 'phase/66-scene-prompt'

This commit is contained in:
Leonid Pershin
2026-08-20 23:43:13 +03:00
27 changed files with 1021 additions and 27 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.");
}
}
}
}
+7
View File
@@ -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))
+20
View File
@@ -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;
}
}
+33 -5
View File
@@ -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,
+5
View File
@@ -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,
+4 -1
View File
@@ -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>