Complete phase 47 romance pack on generic orientation and affinity hooks.

Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
This commit is contained in:
Leonid Pershin
2026-08-20 10:42:42 +03:00
parent cb869e4977
commit d2911c3daf
43 changed files with 1840 additions and 29 deletions
+14 -1
View File
@@ -316,6 +316,8 @@ public sealed class CatalogLoader
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
var colors = new Dictionary<string, ColorDef>(StringComparer.Ordinal);
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -384,6 +386,12 @@ public sealed class CatalogLoader
case DefKind.Topic:
topics[key.Name] = Jsonc.Deserialize<TopicDef>(json);
break;
case DefKind.Orientation:
orientations[key.Name] = Jsonc.Deserialize<OrientationDef>(json);
break;
case DefKind.AffinityRules:
affinity[key.Name] = Jsonc.Deserialize<AffinityRulesDef>(json);
break;
}
}
@@ -410,6 +418,8 @@ public sealed class CatalogLoader
behavior,
colors,
topics,
orientations,
affinity,
ru,
en);
}
@@ -558,7 +568,10 @@ public sealed class CatalogLoader
.Concat(Enumerate(catalog.DayFrames.Values))
.Concat(Enumerate(catalog.Holidays.Values))
.Concat(Enumerate(catalog.Behavior.Values))
.Concat(Enumerate(catalog.Colors.Values));
.Concat(Enumerate(catalog.Colors.Values))
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
+15
View File
@@ -29,6 +29,8 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BehaviorDef> behavior,
IReadOnlyDictionary<string, ColorDef> colors,
IReadOnlyDictionary<string, TopicDef> topics,
IReadOnlyDictionary<string, OrientationDef> orientations,
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -54,6 +56,8 @@ public sealed class DefCatalog
Behavior = behavior;
Colors = colors;
Topics = topics;
Orientations = orientations;
Affinity = affinity;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -113,6 +117,10 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, TopicDef> Topics { get; }
public IReadOnlyDictionary<string, OrientationDef> Orientations { get; }
public IReadOnlyDictionary<string, AffinityRulesDef> Affinity { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
@@ -122,6 +130,9 @@ public sealed class DefCatalog
/// <summary>The one concrete behaviour ruleset, or null when a pack has not defined it.</summary>
public BehaviorDef? BehaviorRules => Behavior.Values.FirstOrDefault(def => !def.Abstract);
/// <summary>The one concrete affinity ruleset, or null when no pack defined it (vanilla).</summary>
public AffinityRulesDef? AffinityRules => Affinity.Values.FirstOrDefault(def => !def.Abstract);
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -150,6 +161,8 @@ public sealed class DefCatalog
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
DefKind.Color => Colors.GetValueOrDefault(defName),
DefKind.Topic => Topics.GetValueOrDefault(defName),
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
_ => null,
};
@@ -231,6 +244,8 @@ public sealed class DefCatalog
BehaviorDef => DefKind.Behavior,
ColorDef => DefKind.Color,
TopicDef => DefKind.Topic,
OrientationDef => DefKind.Orientation,
AffinityRulesDef => DefKind.AffinityRules,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+2
View File
@@ -23,6 +23,8 @@ public enum DefKind
Behavior,
Color,
Topic,
Orientation,
AffinityRules,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+6
View File
@@ -132,6 +132,12 @@ internal static class PackPaths
case "topics":
kind = DefKind.Topic;
return true;
case "orientations":
kind = DefKind.Orientation;
return true;
case "affinity":
kind = DefKind.AffinityRules;
return true;
default:
kind = default;
return false;
+69
View File
@@ -70,6 +70,16 @@ internal static class PeopleDefValidator
ValidateTopic(topic, catalog);
}
foreach (var orientation in catalog.Orientations.Values)
{
ValidateOrientation(orientation);
}
foreach (var affinity in catalog.Affinity.Values)
{
ValidateAffinityRules(affinity);
}
RequireTalkTopics(catalog);
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
@@ -87,6 +97,11 @@ internal static class PeopleDefValidator
throw new ContentLoadException("A catalog may only have one concrete BehaviorDef.");
}
if (catalog.Affinity.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete AffinityRulesDef.");
}
RequireBuildInputs(catalog);
}
@@ -607,6 +622,60 @@ internal static class PeopleDefValidator
}
}
private static void ValidateOrientation(OrientationDef orientation)
{
if (orientation.Abstract)
{
return;
}
if (orientation.Weight < 1)
{
throw new ContentLoadException($"OrientationDef '{orientation.DefName}' weight must be at least 1.");
}
if (!orientation.SameGender && !orientation.OppositeGender)
{
throw new ContentLoadException(
$"OrientationDef '{orientation.DefName}' must allow same gender, opposite gender, or both.");
}
}
private static void ValidateAffinityRules(AffinityRulesDef rules)
{
if (rules.Abstract)
{
return;
}
if (rules.SympathyThreshold < OpinionLabelsMin || rules.PairThreshold < OpinionLabelsMin
|| rules.PairBreakThreshold < OpinionLabelsMin)
{
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' opinion thresholds are out of range.");
}
if (rules.PairMinAge < 0)
{
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' pairMinAge cannot be negative.");
}
if (rules.StudentAgeDeltaYears < 0)
{
throw new ContentLoadException($"AffinityRulesDef '{rules.DefName}' studentAgeDeltaYears cannot be negative.");
}
foreach (var pair in rules.RolePairs)
{
if (!PersonRoles.IsKnown(pair.From) || !PersonRoles.IsKnown(pair.To))
{
throw new ContentLoadException(
$"AffinityRulesDef '{rules.DefName}' role pair '{pair.From}'→'{pair.To}' uses an unknown role.");
}
}
}
private const int OpinionLabelsMin = -100;
/// <summary>A catalog with circle actions but no topics would silently solo-chat — refuse load.</summary>
private static void RequireTalkTopics(DefCatalog catalog)
{
+77
View File
@@ -210,6 +210,83 @@ public sealed class TraitDef : Def
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
/// <summary>
/// Added to the catalog's sympathy opinion threshold. Negative — crush-prone; a pack without
/// affinity rules ignores the field.
/// </summary>
public int AffinityThresholdOffset { get; init; }
/// <summary>
/// Added to the pair-break threshold. Positive — the pair dissolves while opinion is still
/// higher (jealous). Ignored without affinity rules.
/// </summary>
public int AffinityBreakOffset { get; init; }
}
/// <summary>
/// Who this person is drawn to. Present only when a pack ships orientations; vanilla catalogs
/// have none, and the generator then writes no orientation field.
/// </summary>
public sealed class OrientationDef : Def
{
public int Weight { get; init; } = 1;
/// <summary>Attracted to people of the same gender.</summary>
public bool SameGender { get; init; }
/// <summary>Attracted to people of the opposite gender.</summary>
public bool OppositeGender { get; init; }
}
/// <summary>
/// One directed role pairing the affinity overlay may use. Empty <see cref="AffinityRulesDef.RolePairs"/>
/// means the overlay never fires, even if orientations exist.
/// </summary>
public sealed class AffinityRolePair
{
/// <summary><see cref="PersonRoles"/> id of the person who holds the feeling.</summary>
public required string From { get; init; }
/// <summary><see cref="PersonRoles"/> id of the person it is about.</summary>
public required string To { get; init; }
public bool Sympathy { get; init; }
public bool Pair { get; init; }
}
/// <summary>
/// Optional singleton, like <see cref="BehaviorDef"/>. Vanilla core does not ship one; a content
/// pack that wants crushes and pairs adds the numbers. Simulation reads the def if present and
/// no-ops when it is missing — packs are not named in code.
/// </summary>
public sealed class AffinityRulesDef : Def
{
/// <summary>Opinion at or above this (plus trait offset) becomes a one-way crush.</summary>
public int SympathyThreshold { get; init; } = 50;
/// <summary>Mutual crushes at or above this may become a pair, if the role pair allows it.</summary>
public int PairThreshold { get; init; } = 70;
/// <summary>A pair ends when either opinion falls below this (plus jealous offset).</summary>
public int PairBreakThreshold { get; init; } = 20;
/// <summary>Both people must be at least this old to pair. Sympathy has its own age filter.</summary>
public int PairMinAge { get; init; } = 18;
/// <summary>Studentstudent sympathy: age gap no larger than this, or adjacent year.</summary>
public int StudentAgeDeltaYears { get; init; } = 2;
public bool StudentAdjacentYear { get; init; } = true;
/// <summary>Family members never get a crush or pair. Kinship is not this overlay.</summary>
public bool ExcludeFamily { get; init; } = true;
/// <summary>Opinion the rebuffed person loses when a one-way crush is turned down in talk.</summary>
public int RebuffOpinionShift { get; init; } = -8;
public IReadOnlyList<AffinityRolePair> RolePairs { get; init; } = [];
}
/// <summary>Conversation subject. Tags gate appropriateness; language is optional skill help.</summary>