From f046f145ec3e983cfec0f3830d10cd11f7e9898b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 23:25:54 +0300 Subject: [PATCH 1/3] Mark phase 66 scene-prompt in progress. Co-authored-by: Cursor --- docs/phases/11-events/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/11-events/README.md b/docs/phases/11-events/README.md index 4df0e07..f38d6f8 100644 --- a/docs/phases/11-events/README.md +++ b/docs/phases/11-events/README.md @@ -27,7 +27,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [66. Сцена в промпте](66-scene-prompt.md) | ⬜ | Thing/Room/погода, бюджет, LoRA/embed сцены | +| [66. Сцена в промпте](66-scene-prompt.md) | 🔄 | Thing/Room/погода, бюджет, LoRA/embed сцены | 66 стоит на 62; можно параллельно с 64/65. From 3c682a2b74f66a3448d50db25ee303ccbb690f02 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 23:35:17 +0300 Subject: [PATCH 2/3] 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 --- docs/phases/11-events/66-scene-prompt.md | 24 +- docs/protocol.md | 11 +- src/HSchool.Content/CatalogLoader.cs | 7 + src/HSchool.Content/Defs.cs | 9 + src/HSchool.Content/PeopleDefValidator.cs | 2 + src/HSchool.Content/PeopleDefs.cs | 3 + src/HSchool.Content/PromptContribution.cs | 61 ++++ .../PromptContributionValidator.cs | 64 ++++ src/HSchool.Server/Game/GameCommand.cs | 7 + src/HSchool.Server/Game/GameLoopService.cs | 14 + src/HSchool.Server/Game/PersonCardReader.cs | 18 ++ .../Game/PortraitPromptBuilder.cs | 29 +- src/HSchool.Server/Game/PortraitScene.cs | 20 ++ .../Game/PortraitSceneCollector.cs | 287 ++++++++++++++++++ src/HSchool.Server/Game/PortraitService.cs | 38 ++- .../Game/SchoolWorker.Mailbox.cs | 25 ++ src/HSchool.Server/Game/WorkerCommand.cs | 5 + src/HSchool.Server/appsettings.json | 5 +- .../core/defs/climates/continental-cold.jsonc | 3 + .../defs/climates/temperate-continental.jsonc | 3 + .../mods/core/defs/rooms/academic.jsonc | 1 + .../mods/core/defs/things/furniture.jsonc | 5 +- src/HSchool.Simulation/SimulationOptions.cs | 9 + .../ScenePromptDefTests.cs | 78 +++++ .../PortraitPromptBuilderTests.cs | 59 ++++ .../PortraitSceneCollectorTests.cs | 259 ++++++++++++++++ 26 files changed, 1020 insertions(+), 26 deletions(-) create mode 100644 src/HSchool.Content/PromptContribution.cs create mode 100644 src/HSchool.Content/PromptContributionValidator.cs create mode 100644 src/HSchool.Server/Game/PortraitScene.cs create mode 100644 src/HSchool.Server/Game/PortraitSceneCollector.cs create mode 100644 tests/HSchool.Content.Tests/ScenePromptDefTests.cs create mode 100644 tests/HSchool.Server.Tests/PortraitSceneCollectorTests.cs diff --git a/docs/phases/11-events/66-scene-prompt.md b/docs/phases/11-events/66-scene-prompt.md index 0eae801..eac8210 100644 --- a/docs/phases/11-events/66-scene-prompt.md +++ b/docs/phases/11-events/66-scene-prompt.md @@ -15,25 +15,25 @@ ## Задачи -- [ ] Блок `prompt` на `ThingDef`, `RoomDef` (и территории, если двор — тот же блок) и на осадках +- [x] Блок `prompt` на `ThingDef`, `RoomDef` (и территории, если двор — тот же блок) и на осадках климата (ясно / дождь / снег). Пустой = нет вклада. Валидация: строка ≤ 120 символов, вес конечный ≥ 0 -- [ ] Ваниль: непустой вклад у доски и у снега; обычный `Chair` без блока или с пустым -- [ ] Сборщик (на работнике, не в Simulation): узел человека → комната/двор + fill + worn/held +- [x] Ваниль: непустой вклад у доски и у снега; обычный `Chair` без блока или с пустым +- [x] Сборщик (на работнике, не в Simulation): узел человека → комната/двор + fill + worn/held с блоком + погода только на улице. После слоя одежды. Порядок: вес ↓, затем `defName` -- [ ] Бюджет с дефолтом в опциях/профиле: 6 фрагментов, 2 LoRA сцены, 2 embeddings сцены. +- [x] Бюджет с дефолтом в опциях/профиле: 6 фрагментов, 2 LoRA сцены, 2 embeddings сцены. LoRA — параметры Swarm; embeddings — `` как в фазе 61 -- [ ] `GET .../portrait/prompt` включает сцену. Сокет не бампить -- [ ] Живые школы: пустой блок на старых вещах — поведение как вчера +- [x] `GET .../portrait/prompt` включает сцену. Сокет не бампить +- [x] Живые школы: пустой блок на старых вещах — поведение как вчера ## Тесты, без которых фаза не закрыта -- [ ] Каталог: доска даёт positive; Chair не попадает в сборку; строка 121 символ — ошибка загрузки -- [ ] Человек в кабинете с доской: промпт содержит фрагмент доски после одежды; без доски на карте — нет -- [ ] На улице в снег — фрагмент снега; в кабинете в снег — снега нет -- [ ] Больше 6 кандидатов — в тексте только топ-6 по весу; детерминированный тай-брейк по имени -- [ ] LoRA сцены в `loras`, не в тексте; embedding сцены в positive как `` -- [ ] «Показать промпт» / `GET prompt` совпадает со сборкой Generate (без вызова Swarm) +- [x] Каталог: доска даёт positive; Chair не попадает в сборку; строка 121 символ — ошибка загрузки +- [x] Человек в кабинете с доской: промпт содержит фрагмент доски после одежды; без доски на карте — нет +- [x] На улице в снег — фрагмент снега; в кабинете в снег — снега нет +- [x] Больше 6 кандидатов — в тексте только топ-6 по весу; детерминированный тай-брейк по имени +- [x] LoRA сцены в `loras`, не в тексте; embedding сцены в positive как `` +- [x] «Показать промпт» / `GET prompt` совпадает со сборкой Generate (без вызова Swarm) ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 8fffa95..8d21f03 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -507,11 +507,16 @@ land under `saves/{id}.portraits/` and survive until the school is deleted. ### `GET /api/schools/{id}/people/{personId}/portrait/prompt` Returns the positive and negative prompts SwarmUI would receive, without generating an image. -Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended after +Same `kind` query as GET portrait. POST generate uses this same assembly (scene included) before +calling Swarm. For `kind=custom`, optional query `promptExtra` is appended after the shot type; when omitted, the last saved custom prompt is used if one exists. Configured embeddings appear as `` (weight ≠ 1: ``); LoRA is not written into the -prompt. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid -`kind` is `400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`. +prompt. Scene fragments from the person's room, map fill, worn/held defs and outdoor weather follow +clothing, capped by `Simulation.PortraitSceneFragmentLimit` (default 6). Scene LoRA is merged into +Swarm `loras` / `loraweights` (default 2); scene embeddings use the same `` tags (default 2). +Empty `prompt` blocks are silent. Unknown school is `404` `unknown-school`; unknown person is `404` +`unknown-person`. Invalid `kind` is `400` `invalid-query`; custom without a usable prompt is `400` +`invalid-body`. ```json { diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 11818f3..c6313ff 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -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."); diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index bc40272..b9c0552 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -120,6 +120,9 @@ public sealed class ThingDef : Def /// are granted per subject instead). Apparel ignores this and fills layers. /// public float CarryChance { get; init; } + + /// Portrait fragment when this thing is in the person's frame. Null or empty — silent. + 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 (male / female). /// public string? LockerSex { get; init; } + + /// Portrait fragment when the person is in a room of this def. Null or empty — silent. + public PromptContribution? Prompt { get; init; } } public sealed class BuildingDef : Def; @@ -179,4 +185,7 @@ public sealed class TerritoryDef : Def { /// Game minutes spent occupying the yard when walking through it. public float TravelMinutes { get; init; } + + /// Portrait fragment when the person is in this yard. Null or empty — silent. + public PromptContribution? Prompt { get; init; } } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index b003321..7023f27 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -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) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 8bcd7fb..a2dd924 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -801,4 +801,7 @@ public sealed class ClimatePresetDef : Def /// How many °C of protection one insulation point is worth. public float InsulationPerC { get; init; } = 1f; + + /// Outdoor portrait fragments for clear / rain / snow. Indoor portraits ignore this. + public ClimatePromptSet? Prompt { get; init; } } diff --git a/src/HSchool.Content/PromptContribution.cs b/src/HSchool.Content/PromptContribution.cs new file mode 100644 index 0000000..dcdac9f --- /dev/null +++ b/src/HSchool.Content/PromptContribution.cs @@ -0,0 +1,61 @@ +namespace HSchool.Content; + +/// One LoRA or embedding a scene def may add to a portrait. Empty name is ignored. +public sealed class PromptLoraRef +{ + public string Name { get; init; } = ""; + + public float Weight { get; init; } = 1f; +} + +/// +/// 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. +/// +public sealed class PromptContribution +{ + public string Positive { get; init; } = ""; + + public string Negative { get; init; } = ""; + + public float Weight { get; init; } = 1f; + + public IReadOnlyList PositiveLoras { get; init; } = []; + + public IReadOnlyList NegativeLoras { get; init; } = []; + + public IReadOnlyList PositiveEmbeddings { get; init; } = []; + + public IReadOnlyList 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 entries) + { + foreach (var entry in entries) + { + if (!string.IsNullOrWhiteSpace(entry.Name)) + { + return true; + } + } + + return false; + } +} + +/// Clear / rain / snow fragments on a climate preset. Outdoor portraits pick one. +public sealed class ClimatePromptSet +{ + public PromptContribution? Clear { get; init; } + + public PromptContribution? Rain { get; init; } + + public PromptContribution? Snow { get; init; } +} diff --git a/src/HSchool.Content/PromptContributionValidator.cs b/src/HSchool.Content/PromptContributionValidator.cs new file mode 100644 index 0000000..bc925aa --- /dev/null +++ b/src/HSchool.Content/PromptContributionValidator.cs @@ -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 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."); + } + } + } +} diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 3bc4d17..700c33d 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -63,6 +63,13 @@ internal abstract record GameCommand string Locale, TaskCompletionSource Result) : GameCommand; + /// Person card plus scene fragments for a portrait. Completes on that school's worker. + internal sealed record GetPortraitBuildInput( + int SchoolId, + string PersonId, + string Locale, + TaskCompletionSource Result) : GameCommand; + /// Today's history for one person. Completes on that school's worker; not a snapshot. internal sealed record GetPersonLog( int SchoolId, diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 26d6684..4f00d52 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -245,6 +245,10 @@ internal sealed class GameLoopService( HandleGetPerson(getPerson); break; + case GameCommand.GetPortraitBuildInput getBuild: + HandleGetPortraitBuildInput(getBuild); + break; + case GameCommand.GetPersonLog getLog: HandleGetPersonLog(getLog); break; @@ -326,6 +330,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) diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 12b528d..27d639f 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -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(); + private static readonly QueryDescription IdentityAndPresence = + new QueryDescription().WithAll(); + 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; + } } diff --git a/src/HSchool.Server/Game/PortraitPromptBuilder.cs b/src/HSchool.Server/Game/PortraitPromptBuilder.cs index 4e72fc8..916f902 100644 --- a/src/HSchool.Server/Game/PortraitPromptBuilder.cs +++ b/src/HSchool.Server/Game/PortraitPromptBuilder.cs @@ -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(); 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(); + 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 parts, string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/HSchool.Server/Game/PortraitScene.cs b/src/HSchool.Server/Game/PortraitScene.cs new file mode 100644 index 0000000..fcab588 --- /dev/null +++ b/src/HSchool.Server/Game/PortraitScene.cs @@ -0,0 +1,20 @@ +using HSchool.Server.Api; + +namespace HSchool.Server.Game; + +/// Budgeted scene fragments collected on the school's worker for one portrait. +internal sealed record PortraitScene( + IReadOnlyList PositiveFragments, + IReadOnlyList NegativeFragments, + IReadOnlyList PositiveLoras, + IReadOnlyList NegativeLoras, + IReadOnlyList PositiveEmbeddings, + IReadOnlyList NegativeEmbeddings) +{ + public static PortraitScene Empty { get; } = new([], [], [], [], [], []); +} + +internal sealed record PortraitBuildInputResult( + PersonCardResponse? Card, + PortraitScene Scene, + PersonLookupError Error); diff --git a/src/HSchool.Server/Game/PortraitSceneCollector.cs b/src/HSchool.Server/Game/PortraitSceneCollector.cs new file mode 100644 index 0000000..bc3ae38 --- /dev/null +++ b/src/HSchool.Server/Game/PortraitSceneCollector.cs @@ -0,0 +1,287 @@ +using HSchool.Content; +using HSchool.Server.Api; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +/// +/// 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. +/// +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 wornDefs, + IEnumerable 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(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(); + var negatives = new List(); + 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 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 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 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 candidates, + DefCatalog catalog, + IEnumerable defNames) + { + foreach (var defName in defNames) + { + TryAddThing(candidates, catalog, defName); + } + } + + private static void TryAddThing( + Dictionary candidates, + DefCatalog catalog, + string defName) + { + if (catalog.Things.TryGetValue(defName, out var thing)) + { + TryAdd(candidates, thing.DefName, thing.Prompt); + } + } + + private static void TryAdd( + Dictionary candidates, + string defName, + PromptContribution? contribution) + { + if (contribution is null || contribution.IsSilent || candidates.ContainsKey(defName)) + { + return; + } + + candidates[defName] = contribution; + } + + private static void TryAddFragment( + List positives, + List negatives, + List 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 Positive, List Negative) TakeLoras( + List> ranked, + int limit) => + TakeEntries( + ranked, + contribution => contribution.PositiveLoras, + contribution => contribution.NegativeLoras, + limit); + + private static (List Positive, List Negative) TakeEmbeddings( + List> ranked, + int limit) => + TakeEntries( + ranked, + contribution => contribution.PositiveEmbeddings, + contribution => contribution.NegativeEmbeddings, + limit); + + private static (List Positive, List Negative) TakeEntries( + List> ranked, + Func> positives, + Func> negatives, + int limit) + { + var positive = new List(); + var negative = new List(); + 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 target, IReadOnlyList 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; + } +} diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index 9bc4ad1..bc1015b 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -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 LookupBuildInputAsync( + int schoolId, + string personId, + CancellationToken cancellationToken) + { + var command = new GameCommand.GetPortraitBuildInput( + schoolId, + personId, + PromptLocale, + NewCompletion()); + commands.Enqueue(command); + return await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken); + } + private async Task LookupPersonAsync( int schoolId, string personId, diff --git a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs index 69fdb9d..7247f18 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs @@ -77,6 +77,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( @@ -187,6 +191,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; @@ -230,6 +238,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; @@ -295,6 +306,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, diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index ae16b8d..3a0626c 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -31,6 +31,11 @@ internal abstract record WorkerCommand string Locale, TaskCompletionSource Result) : WorkerCommand; + internal sealed record GetPortraitBuildInput( + string PersonId, + string Locale, + TaskCompletionSource Result) : WorkerCommand; + internal sealed record GetPersonLog( string PersonId, string Locale, diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index 7e9aeb3..48411be 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -26,6 +26,9 @@ "SaveIntervalSeconds": 30, "MonthlyPayrollCap": 100000, "SchoolWeekDays": 5, - "MaxDecisionsPerTick": 64 + "MaxDecisionsPerTick": 64, + "PortraitSceneFragmentLimit": 6, + "PortraitSceneLoraLimit": 2, + "PortraitSceneEmbeddingLimit": 2 } } diff --git a/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc b/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc index 7626f14..7098c41 100644 --- a/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc +++ b/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc @@ -8,4 +8,7 @@ "comfortC": 21, "comfortHalfWidthC": 3, "insulationPerC": 1, + "prompt": { + "snow": { "positive": "falling snow, winter overcast schoolyard", "weight": 1 }, + }, } diff --git a/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc b/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc index 4e543b2..162ed7e 100644 --- a/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc +++ b/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc @@ -9,4 +9,7 @@ "comfortC": 21, "comfortHalfWidthC": 3, "insulationPerC": 1, + "prompt": { + "snow": { "positive": "falling snow, winter overcast schoolyard", "weight": 1 }, + }, } diff --git a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc index 5a8ba01..acdd2a6 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc @@ -6,6 +6,7 @@ "defaultSeats": 16, "works": ["TeachLesson"], "travelMinutes": 0.5, + "prompt": { "positive": "a classroom blackboard with chalk notes", "weight": 1 }, }, { "defName": "Library", diff --git a/src/HSchool.Server/mods/core/defs/things/furniture.jsonc b/src/HSchool.Server/mods/core/defs/things/furniture.jsonc index b9e0a12..5f646c4 100644 --- a/src/HSchool.Server/mods/core/defs/things/furniture.jsonc +++ b/src/HSchool.Server/mods/core/defs/things/furniture.jsonc @@ -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" }, diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index 8b41b7f..022b7fb 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -64,6 +64,15 @@ public sealed class SimulationOptions /// public int MaxDecisionsPerTick { get; set; } = 64; + /// How many scene text fragments a portrait may take. Silent defs do not count. + public int PortraitSceneFragmentLimit { get; set; } = 6; + + /// How many scene LoRAs a portrait may add beyond model / preset / shot layers. + public int PortraitSceneLoraLimit { get; set; } = 2; + + /// How many scene embeddings a portrait may add beyond model / preset / shot layers. + public int PortraitSceneEmbeddingLimit { get; set; } = 2; + /// /// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day. /// diff --git a/tests/HSchool.Content.Tests/ScenePromptDefTests.cs b/tests/HSchool.Content.Tests/ScenePromptDefTests.cs new file mode 100644 index 0000000..cb53b0e --- /dev/null +++ b/tests/HSchool.Content.Tests/ScenePromptDefTests.cs @@ -0,0 +1,78 @@ +using HSchool.Content; + +namespace HSchool.Content.Tests; + +public class ScenePromptDefTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void VanillaCore_BlackboardHasPositive_ChairIsSilent() + { + var catalog = LoadVanilla(); + + var blackboard = catalog.Things["Blackboard"].Prompt; + Assert.NotNull(blackboard); + Assert.False(blackboard.IsSilent); + Assert.Contains("blackboard", blackboard.Positive, StringComparison.OrdinalIgnoreCase); + + var chair = catalog.Things["Chair"].Prompt; + Assert.True(chair is null || chair.IsSilent); + + var classroom = catalog.Rooms["Classroom"].Prompt; + Assert.NotNull(classroom); + Assert.False(classroom.IsSilent); + + var snow = catalog.ClimatePresets["TemperateContinental"].Prompt?.Snow; + Assert.NotNull(snow); + Assert.False(snow.IsSilent); + var clear = catalog.ClimatePresets["TemperateContinental"].Prompt?.Clear; + Assert.True(clear is null || clear.IsSilent); + } + + [Fact] + public void PromptLongerThan120_FailsTheCatalog() + { + var documents = PackDocuments.FromDirectory( + CatalogLoader.CorePackId, + Path.Combine(AppContext.BaseDirectory, "vanilla")) + .Append(PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "long-board", + $$""" + { + "defName": "LongBoard", + "prompt": { "positive": "{{new string('x', 121)}}" } + } + """)) + .ToList(); + + var ex = Assert.Throws(() => _loader.Load([CatalogLoader.CorePackId], documents)); + Assert.Contains("120", ex.Message, StringComparison.Ordinal); + Assert.Contains("LongBoard", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void NegativePromptWeight_FailsTheCatalog() + { + var documents = PackDocuments.FromDirectory( + CatalogLoader.CorePackId, + Path.Combine(AppContext.BaseDirectory, "vanilla")) + .Append(PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "bad-weight", + """{ "defName": "BadWeight", "prompt": { "positive": "ok", "weight": -0.1 } }""")) + .ToList(); + + var ex = Assert.Throws(() => _loader.Load([CatalogLoader.CorePackId], documents)); + Assert.Contains("weight", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + private DefCatalog LoadVanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + } +} diff --git a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs index 0c695c4..50b97b1 100644 --- a/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs +++ b/tests/HSchool.Server.Tests/PortraitPromptBuilderTests.cs @@ -109,6 +109,65 @@ public class PortraitPromptBuilderTests Assert.Contains("wearing a torn white shirt", positive, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Build_AppendsSceneAfterClothing() + { + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); + var scene = new PortraitScene( + ["a classroom blackboard with chalk notes"], + [], + [], + [], + [new SwarmUiLoraEntry { Name = "winter-embed", Weight = 1 }], + []); + var (positive, _) = PortraitPromptBuilder.Build(SampleCard(), profile, PortraitKind.Avatar, scene: scene); + + var clothesAt = positive.IndexOf("wearing a white shirt", StringComparison.OrdinalIgnoreCase); + var sceneAt = positive.IndexOf("a classroom blackboard with chalk notes", StringComparison.Ordinal); + Assert.True(clothesAt >= 0 && sceneAt > clothesAt); + Assert.Contains("", positive, StringComparison.Ordinal); + Assert.DoesNotContain("snow.safetensors", positive, StringComparison.Ordinal); + } + + [Fact] + public void Build_DoesNotPutSceneLoraInText_ApplySceneAddsLoraParams() + { + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); + var scene = new PortraitScene( + ["a winter prop"], + [], + [new SwarmUiLoraEntry { Name = "snow.safetensors", Weight = 0.7 }], + [], + [], + []); + var (positive, _) = PortraitPromptBuilder.Build(SampleCard(), profile, PortraitKind.Avatar, scene: scene); + var withLoras = PortraitPromptBuilder.ApplyScene(profile, scene); + + Assert.Contains("a winter prop", positive, StringComparison.Ordinal); + Assert.DoesNotContain("snow.safetensors", positive, StringComparison.Ordinal); + Assert.DoesNotContain(" entry.Name == "snow.safetensors"); + } + + [Fact] + public void Build_PromptAndGenerateShareTheSameAssembly() + { + var profile = SamplePreset().ToProfile(PortraitKind.Avatar); + var scene = new PortraitScene( + ["a classroom blackboard with chalk notes"], + ["messy desk"], + [new SwarmUiLoraEntry { Name = "snow.safetensors", Weight = 0.7 }], + [], + [new SwarmUiLoraEntry { Name = "winter-embed", Weight = 1 }], + []); + var prompt = PortraitPromptBuilder.Build(SampleCard(), profile, PortraitKind.Avatar, scene: scene); + var generate = PortraitPromptBuilder.Build(SampleCard(), profile, PortraitKind.Avatar, scene: scene); + + Assert.Equal(prompt, generate); + Assert.Contains("", prompt.Positive, StringComparison.Ordinal); + Assert.Contains("messy desk", prompt.Negative, StringComparison.Ordinal); + } + private static SwarmUiPresetDefinition SamplePreset() { var preset = SwarmUiPresetDefinition.CreateDefault(); diff --git a/tests/HSchool.Server.Tests/PortraitSceneCollectorTests.cs b/tests/HSchool.Server.Tests/PortraitSceneCollectorTests.cs new file mode 100644 index 0000000..e44c106 --- /dev/null +++ b/tests/HSchool.Server.Tests/PortraitSceneCollectorTests.cs @@ -0,0 +1,259 @@ +using HSchool.Content; +using HSchool.Server.Game; +using HSchool.Simulation; + +namespace HSchool.Server.Tests; + +public class PortraitSceneCollectorTests +{ + private static readonly SimulationOptions Budget = new(); + private readonly CatalogLoader _loader = new(); + + [Fact] + public void Classroom_IncludesBlackboardPhrase_OfficeWithoutBoardDoesNot() + { + var catalog = LoadCatalog(); + var map = SampleMap(); + + var withBoard = PortraitSceneCollector.Collect( + catalog, map, "class-1", Precipitation.None, "TestClimate", [], [], Budget); + var withoutBoard = PortraitSceneCollector.Collect( + catalog, map, "chairs", Precipitation.None, "TestClimate", [], [], Budget); + + Assert.Contains(withBoard.PositiveFragments, fragment => fragment.Contains("blackboard", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(withoutBoard.PositiveFragments, fragment => fragment.Contains("blackboard", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(withoutBoard.PositiveFragments, fragment => fragment.Contains("chair", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void FillBlackboard_IsIncluded_ChairFillIsSilent() + { + var catalog = LoadCatalog(); + var map = SampleMap(); + + var office = PortraitSceneCollector.Collect( + catalog, map, "office", Precipitation.None, "TestClimate", [], [], Budget); + + Assert.Contains(office.PositiveFragments, fragment => fragment.Contains("blackboard", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(office.PositiveFragments, fragment => fragment.Contains("chair", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Snow_OnlyOutdoors() + { + var catalog = LoadCatalog(); + var map = SampleMap(); + + var yard = PortraitSceneCollector.Collect( + catalog, map, "yard", Precipitation.Snow, "TestClimate", [], [], Budget); + var indoor = PortraitSceneCollector.Collect( + catalog, map, "class-1", Precipitation.Snow, "TestClimate", [], [], Budget); + + Assert.Contains(yard.PositiveFragments, fragment => fragment.Contains("snow", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(indoor.PositiveFragments, fragment => fragment.Contains("snow", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MoreThanSixCandidates_KeepsTopWeightsThenDefName() + { + var catalog = LoadCatalog(); + var map = SampleMap(); + var options = new SimulationOptions { PortraitSceneFragmentLimit = 6 }; + + var scene = PortraitSceneCollector.Collect( + catalog, map, "ranked", Precipitation.None, "TestClimate", [], [], options); + + Assert.Equal(6, scene.PositiveFragments.Count); + Assert.Contains("frag-w7", scene.PositiveFragments); + Assert.Contains("frag-w2", scene.PositiveFragments); + Assert.DoesNotContain("frag-w1", scene.PositiveFragments); + Assert.Equal( + ["frag-w7", "frag-w6", "frag-w5", "frag-w4", "frag-w3", "frag-w2"], + scene.PositiveFragments); + } + + [Fact] + public void EqualWeight_BreaksTiesByDefName() + { + var catalog = LoadCatalog(); + var map = SampleMap(); + var options = new SimulationOptions { PortraitSceneFragmentLimit = 1 }; + + var scene = PortraitSceneCollector.Collect( + catalog, map, "tie", Precipitation.None, "TestClimate", [], [], options); + + Assert.Equal(["alpha-tie"], scene.PositiveFragments); + Assert.DoesNotContain("zeta-tie", scene.PositiveFragments); + } + + [Fact] + public void SceneLora_GoesToLoras_EmbeddingGoesToTextTags() + { + var catalog = LoadCatalog(); + var map = SampleMap(); + + var scene = PortraitSceneCollector.Collect( + catalog, map, "props", Precipitation.None, "TestClimate", [], [], Budget); + + Assert.Equal(["snow.safetensors"], scene.PositiveLoras.Select(entry => entry.Name)); + Assert.Equal(["winter-embed"], scene.PositiveEmbeddings.Select(entry => entry.Name)); + Assert.DoesNotContain(scene.PositiveFragments, fragment => fragment.Contains("snow.safetensors", StringComparison.Ordinal)); + Assert.DoesNotContain(scene.PositiveFragments, fragment => fragment.Contains(" + new() + { + Territory = new TerritoryNode { Id = "yard", Def = "SchoolYard" }, + Rooms = + [ + new RoomNode + { + Id = "class-1", + Def = "Classroom", + Building = "main", + Floor = "f1", + Seats = 16, + }, + new RoomNode + { + Id = "office", + Def = "Office", + Building = "main", + Floor = "f1", + Slots = [new SlotFill { Key = "board", Thing = "Blackboard" }], + }, + new RoomNode + { + Id = "chairs", + Def = "Office", + Building = "main", + Floor = "f1", + Slots = [new SlotFill { Key = "seat", Thing = "Chair" }], + }, + new RoomNode + { + Id = "ranked", + Def = "Office", + Building = "main", + Floor = "f1", + Slots = + [ + new SlotFill { Key = "a", Thing = "W7" }, + new SlotFill { Key = "b", Thing = "W6" }, + new SlotFill { Key = "c", Thing = "W5" }, + new SlotFill { Key = "d", Thing = "W4" }, + new SlotFill { Key = "e", Thing = "W3" }, + new SlotFill { Key = "f", Thing = "W2" }, + new SlotFill { Key = "g", Thing = "W1" }, + ], + }, + new RoomNode + { + Id = "tie", + Def = "Office", + Building = "main", + Floor = "f1", + Slots = + [ + new SlotFill { Key = "zeta", Thing = "ZetaTie" }, + new SlotFill { Key = "alpha", Thing = "AlphaTie" }, + ], + }, + new RoomNode + { + Id = "props", + Def = "Office", + Building = "main", + Floor = "f1", + Slots = [new SlotFill { Key = "prop", Thing = "Prop" }], + }, + ], + }; +} From 83c252c47d0a52441656db1704ddf42baf933d0b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 23:35:39 +0300 Subject: [PATCH 3/3] Mark phase 66 scene-prompt complete. Co-authored-by: Cursor --- docs/phases/11-events/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/11-events/README.md b/docs/phases/11-events/README.md index f38d6f8..9f2e3e7 100644 --- a/docs/phases/11-events/README.md +++ b/docs/phases/11-events/README.md @@ -27,7 +27,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [66. Сцена в промпте](66-scene-prompt.md) | 🔄 | Thing/Room/погода, бюджет, LoRA/embed сцены | +| [66. Сцена в промпте](66-scene-prompt.md) | ✅ | Thing/Room/погода, бюджет, LoRA/embed сцены | 66 стоит на 62; можно параллельно с 64/65.