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:
@@ -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 — `<embed:…>` как в фазе 61
|
||||
- [ ] `GET .../portrait/prompt` включает сцену. Сокет не бампить
|
||||
- [ ] Живые школы: пустой блок на старых вещах — поведение как вчера
|
||||
- [x] `GET .../portrait/prompt` включает сцену. Сокет не бампить
|
||||
- [x] Живые школы: пустой блок на старых вещах — поведение как вчера
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Каталог: доска даёт positive; Chair не попадает в сборку; строка 121 символ — ошибка загрузки
|
||||
- [ ] Человек в кабинете с доской: промпт содержит фрагмент доски после одежды; без доски на карте — нет
|
||||
- [ ] На улице в снег — фрагмент снега; в кабинете в снег — снега нет
|
||||
- [ ] Больше 6 кандидатов — в тексте только топ-6 по весу; детерминированный тай-брейк по имени
|
||||
- [ ] LoRA сцены в `loras`, не в тексте; embedding сцены в positive как `<embed:…>`
|
||||
- [ ] «Показать промпт» / `GET prompt` совпадает со сборкой Generate (без вызова Swarm)
|
||||
- [x] Каталог: доска даёт positive; Chair не попадает в сборку; строка 121 символ — ошибка загрузки
|
||||
- [x] Человек в кабинете с доской: промпт содержит фрагмент доски после одежды; без доски на карте — нет
|
||||
- [x] На улице в снег — фрагмент снега; в кабинете в снег — снега нет
|
||||
- [x] Больше 6 кандидатов — в тексте только топ-6 по весу; детерминированный тай-брейк по имени
|
||||
- [x] LoRA сцены в `loras`, не в тексте; embedding сцены в positive как `<embed:…>`
|
||||
- [x] «Показать промпт» / `GET prompt` совпадает со сборкой Generate (без вызова Swarm)
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
+8
-3
@@ -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 `<embed:name>` (weight ≠ 1: `<embed:name:w>`); 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 `<embed:…>` 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
|
||||
{
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,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>
|
||||
/// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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));
|
||||
}
|
||||
}
|
||||
@@ -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("<embed:winter-embed>", 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("<lora:", positive, StringComparison.Ordinal);
|
||||
Assert.Contains(withLoras.PositiveLoras, entry => 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("<embed:winter-embed>", prompt.Positive, StringComparison.Ordinal);
|
||||
Assert.Contains("messy desk", prompt.Negative, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static SwarmUiPresetDefinition SamplePreset()
|
||||
{
|
||||
var preset = SwarmUiPresetDefinition.CreateDefault();
|
||||
|
||||
@@ -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("<embed:", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private DefCatalog LoadCatalog()
|
||||
{
|
||||
return _loader.Load(
|
||||
[CatalogLoader.CorePackId],
|
||||
[
|
||||
new ContentDocument(
|
||||
CatalogLoader.CorePackId,
|
||||
"defs/things/scene.jsonc",
|
||||
"""
|
||||
[
|
||||
{ "defName": "Blackboard", "prompt": { "positive": "a classroom blackboard with chalk notes", "weight": 1 } },
|
||||
{ "defName": "Chair" },
|
||||
{ "defName": "StudentDesk", "pupilSlots": 1 },
|
||||
{ "defName": "W7", "prompt": { "positive": "frag-w7", "weight": 7 } },
|
||||
{ "defName": "W6", "prompt": { "positive": "frag-w6", "weight": 6 } },
|
||||
{ "defName": "W5", "prompt": { "positive": "frag-w5", "weight": 5 } },
|
||||
{ "defName": "W4", "prompt": { "positive": "frag-w4", "weight": 4 } },
|
||||
{ "defName": "W3", "prompt": { "positive": "frag-w3", "weight": 3 } },
|
||||
{ "defName": "W2", "prompt": { "positive": "frag-w2", "weight": 2 } },
|
||||
{ "defName": "W1", "prompt": { "positive": "frag-w1", "weight": 1 } },
|
||||
{ "defName": "AlphaTie", "prompt": { "positive": "alpha-tie", "weight": 1 } },
|
||||
{ "defName": "ZetaTie", "prompt": { "positive": "zeta-tie", "weight": 1 } },
|
||||
{
|
||||
"defName": "Prop",
|
||||
"prompt": {
|
||||
"positive": "a winter prop",
|
||||
"weight": 1,
|
||||
"positiveLoras": [{ "name": "snow.safetensors", "weight": 0.7 }],
|
||||
"positiveEmbeddings": [{ "name": "winter-embed", "weight": 1 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
"""),
|
||||
new ContentDocument(
|
||||
CatalogLoader.CorePackId,
|
||||
"defs/rooms/scene.jsonc",
|
||||
"""
|
||||
[
|
||||
{
|
||||
"defName": "Classroom",
|
||||
"homeroom": true,
|
||||
"seatThing": "StudentDesk",
|
||||
"defaultSeats": 16,
|
||||
"travelMinutes": 0.5,
|
||||
"prompt": { "positive": "a classroom blackboard with chalk notes", "weight": 1 }
|
||||
},
|
||||
{
|
||||
"defName": "Office",
|
||||
"travelMinutes": 0.5,
|
||||
"slots": [
|
||||
{ "key": "board", "thing": "Blackboard" },
|
||||
{ "key": "seat", "thing": "Chair" },
|
||||
{ "key": "a", "thing": "W7" },
|
||||
{ "key": "b", "thing": "W6" },
|
||||
{ "key": "c", "thing": "W5" },
|
||||
{ "key": "d", "thing": "W4" },
|
||||
{ "key": "e", "thing": "W3" },
|
||||
{ "key": "f", "thing": "W2" },
|
||||
{ "key": "g", "thing": "W1" },
|
||||
{ "key": "alpha", "thing": "AlphaTie" },
|
||||
{ "key": "zeta", "thing": "ZetaTie" },
|
||||
{ "key": "prop", "thing": "Prop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
"""),
|
||||
new ContentDocument(
|
||||
CatalogLoader.CorePackId,
|
||||
"defs/territories/yard.jsonc",
|
||||
"""{ "defName": "SchoolYard", "travelMinutes": 3 }"""),
|
||||
new ContentDocument(
|
||||
CatalogLoader.CorePackId,
|
||||
"defs/climates/test.jsonc",
|
||||
"""
|
||||
{
|
||||
"defName": "TestClimate",
|
||||
"monthlyNorms": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
"daySpread": 0,
|
||||
"hourSpread": 0,
|
||||
"precipitationChance": 0,
|
||||
"prompt": { "snow": { "positive": "falling snow, winter overcast schoolyard", "weight": 1 } }
|
||||
}
|
||||
"""),
|
||||
]);
|
||||
}
|
||||
|
||||
private static MapLayout SampleMap() =>
|
||||
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" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user