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:
@@ -206,6 +206,10 @@ const ru = {
|
||||
peopleConnectionsEnemies: 'Враги',
|
||||
peopleConnectionsSearch: 'Поиск по связям',
|
||||
peopleConnectionsEmpty: 'Нет других связей.',
|
||||
peopleConnectionsCrushes: 'Симпатии',
|
||||
peopleConnectionsAdmirers: 'Нравятся этому человеку',
|
||||
peopleConnectionsPair: 'Пара',
|
||||
peopleOrientation: 'Ориентация',
|
||||
peopleOpinionValue: '{label} ({value})',
|
||||
peopleAtHome: 'дома',
|
||||
peopleApparelFit: 'Уместность: {value}',
|
||||
@@ -563,6 +567,10 @@ const en: Messages = {
|
||||
peopleConnectionsEnemies: 'Enemies',
|
||||
peopleConnectionsSearch: 'Search connections',
|
||||
peopleConnectionsEmpty: 'No other connections.',
|
||||
peopleConnectionsCrushes: 'Crushes',
|
||||
peopleConnectionsAdmirers: 'Admirers',
|
||||
peopleConnectionsPair: 'Couple',
|
||||
peopleOrientation: 'Orientation',
|
||||
peopleOpinionValue: '{label} ({value})',
|
||||
peopleAtHome: 'home',
|
||||
peopleApparelFit: 'Fit: {value}',
|
||||
|
||||
@@ -181,6 +181,12 @@ export interface MapLayout {
|
||||
links: { a: string; b: string }[];
|
||||
}
|
||||
|
||||
export interface TopicInfo {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
readonly tags: readonly string[];
|
||||
}
|
||||
|
||||
export interface CatalogResponse {
|
||||
readonly territories: readonly DefInfo[];
|
||||
readonly buildings: readonly DefInfo[];
|
||||
@@ -192,6 +198,8 @@ export interface CatalogResponse {
|
||||
readonly subjects: readonly SubjectInfo[];
|
||||
readonly dayFrame: DayFrameInfo | null;
|
||||
readonly holidays: readonly HolidayInfo[];
|
||||
/** Conversation subjects. A content pack may add more; vanilla has no romance tags. */
|
||||
readonly topics?: readonly TopicInfo[];
|
||||
}
|
||||
|
||||
export async function fetchMods(lang: string): Promise<readonly ModInfo[]> {
|
||||
@@ -301,6 +309,9 @@ export interface PersonConnections {
|
||||
readonly friends: readonly PersonOpinionLink[];
|
||||
readonly enemies: readonly PersonOpinionLink[];
|
||||
readonly others: readonly PersonOpinionLink[];
|
||||
readonly crushes?: readonly PersonOpinionLink[];
|
||||
readonly admirers?: readonly PersonOpinionLink[];
|
||||
readonly pair?: PersonRel | null;
|
||||
}
|
||||
|
||||
export interface PersonCard {
|
||||
@@ -341,6 +352,7 @@ export interface PersonCard {
|
||||
readonly hasFullBody: boolean;
|
||||
readonly customPortraitPrompt: string | null;
|
||||
readonly connections: PersonConnections | null;
|
||||
readonly orientation?: DefLabel | null;
|
||||
}
|
||||
|
||||
export interface WornItem {
|
||||
|
||||
@@ -291,6 +291,35 @@ describe('renderPersonCard', () => {
|
||||
expect(root.querySelector('.people__log-tools')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides crush columns without orientation and shows them with a pack', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), () => {}, { tab: 'connections' });
|
||||
expect(root.textContent).not.toContain('Симпатии');
|
||||
|
||||
root.replaceChildren();
|
||||
renderPersonCard(
|
||||
root,
|
||||
card({
|
||||
orientation: { defName: 'Heterosexual', label: 'гетеро' },
|
||||
connections: {
|
||||
family: { parents: [], children: [], siblings: [], partners: [] },
|
||||
friends: [],
|
||||
enemies: [],
|
||||
others: [],
|
||||
crushes: [{ id: 't1', fullName: 'Учитель', female: false, opinion: 60, opinionLabel: 'друзья' }],
|
||||
admirers: [],
|
||||
pair: null,
|
||||
},
|
||||
}),
|
||||
() => {},
|
||||
{ tab: 'connections' },
|
||||
);
|
||||
expect(root.textContent).toContain('гетеро');
|
||||
expect(root.textContent).toContain('Симпатии');
|
||||
expect(root.textContent).toContain('Учитель');
|
||||
});
|
||||
|
||||
it('pages the log on the now tab', () => {
|
||||
setLocale('ru');
|
||||
const onLogPage = vi.fn();
|
||||
|
||||
@@ -266,6 +266,12 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id:
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.orientation != null) {
|
||||
const orientation = section(t('peopleOrientation'));
|
||||
orientation.append(el('p', { class: 'people__orientation', text: card.orientation.label }));
|
||||
parent.append(orientation);
|
||||
}
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative);
|
||||
@@ -275,6 +281,17 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id:
|
||||
parent.append(family);
|
||||
}
|
||||
|
||||
if (connections.pair != null) {
|
||||
appendRelativesWithOpinion(parent, t('peopleConnectionsPair'), [connections.pair], onRelative);
|
||||
}
|
||||
|
||||
const crushes = connections.crushes ?? [];
|
||||
const admirers = connections.admirers ?? [];
|
||||
if (card.orientation != null || crushes.length > 0 || admirers.length > 0) {
|
||||
appendOpinionLinks(parent, t('peopleConnectionsCrushes'), crushes, onRelative);
|
||||
appendOpinionLinks(parent, t('peopleConnectionsAdmirers'), admirers, onRelative);
|
||||
}
|
||||
|
||||
appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative);
|
||||
appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>Student–student 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>
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>One overlay change the simulation can log. Type is a person-log id, not a pack name.</summary>
|
||||
public sealed record AffinityEvent(string PersonId, string Type, string? OtherId);
|
||||
|
||||
/// <summary>
|
||||
/// Crush and pair overlay driven by <see cref="AffinityRulesDef"/>. No-ops when the catalog has
|
||||
/// no rules or no orientations — packs are not named here.
|
||||
/// </summary>
|
||||
public static class Affinity
|
||||
{
|
||||
public const string Crush = "affinity-crush";
|
||||
public const string Pair = "affinity-pair";
|
||||
public const string PairBreak = "affinity-pair-break";
|
||||
public const string Tease = "affinity-tease";
|
||||
public const string Rebuff = "affinity-rebuff";
|
||||
|
||||
public static IReadOnlyList<AffinityEvent> Refresh(DefCatalog catalog, Roster roster, DateTime asOf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
|
||||
var rules = catalog.AffinityRules;
|
||||
if (rules is null || catalog.Orientations.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||
var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
var events = new List<AffinityEvent>();
|
||||
var ordered = roster.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
|
||||
|
||||
foreach (var person in ordered)
|
||||
{
|
||||
DropInvalid(catalog, rules, roster, people, years, person, asOf, events);
|
||||
}
|
||||
|
||||
foreach (var person in ordered)
|
||||
{
|
||||
GainCrushes(catalog, rules, roster, people, years, person, asOf, events);
|
||||
}
|
||||
|
||||
foreach (var person in ordered)
|
||||
{
|
||||
TryFormPair(catalog, rules, roster, people, years, person, asOf, events);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AffinityEvent> RebuffTalk(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
IReadOnlyList<string> talkedIds,
|
||||
DateTime asOf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
ArgumentNullException.ThrowIfNull(talkedIds);
|
||||
|
||||
var rules = catalog.AffinityRules;
|
||||
if (rules is null || catalog.Orientations.Count == 0 || talkedIds.Count < 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||
var present = talkedIds.Where(people.ContainsKey).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
||||
var events = new List<AffinityEvent>();
|
||||
foreach (var fromId in present)
|
||||
{
|
||||
var from = people[fromId];
|
||||
if (from.Bonds is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var crushId in from.Bonds.Crushes.ToArray())
|
||||
{
|
||||
if (!people.TryGetValue(crushId, out var to) || !present.Contains(crushId, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!from.IsStudent || !to.IsStaff)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RoleAllows(rules, to, from, pair: false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mutable = OpinionGenerator.EnsureMutableOpinions(from);
|
||||
var current = OpinionStore.Get(mutable, to.Id) ?? 0;
|
||||
OpinionStore.Set(mutable, to.Id, current + rules.RebuffOpinionShift);
|
||||
events.Add(new AffinityEvent(from.Id, Rebuff, to.Id));
|
||||
}
|
||||
}
|
||||
|
||||
_ = asOf;
|
||||
return events;
|
||||
}
|
||||
|
||||
public static bool CanHaveSympathy(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
Person from,
|
||||
Person to,
|
||||
DateTime asOf)
|
||||
{
|
||||
var rules = catalog.AffinityRules;
|
||||
if (rules is null || from.Id.Equals(to.Id, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
return CanSympathy(catalog, rules, roster, years, from, to, asOf);
|
||||
}
|
||||
|
||||
public static bool CanFormPair(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
Person from,
|
||||
Person to,
|
||||
DateTime asOf)
|
||||
{
|
||||
var rules = catalog.AffinityRules;
|
||||
if (rules is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var years = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
|
||||
return CanPair(catalog, rules, roster, years, from, to, asOf);
|
||||
}
|
||||
|
||||
private static void DropInvalid(
|
||||
DefCatalog catalog,
|
||||
AffinityRulesDef rules,
|
||||
Roster roster,
|
||||
Dictionary<string, Person> people,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person person,
|
||||
DateTime asOf,
|
||||
List<AffinityEvent> events)
|
||||
{
|
||||
var bonds = EnsureBonds(person);
|
||||
if (bonds is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var keep = new List<string>();
|
||||
foreach (var crushId in bonds.Crushes)
|
||||
{
|
||||
if (people.TryGetValue(crushId, out var target)
|
||||
&& CanSympathy(catalog, rules, roster, years, person, target, asOf))
|
||||
{
|
||||
keep.Add(crushId);
|
||||
}
|
||||
}
|
||||
|
||||
if (keep.Count != bonds.Crushes.Count)
|
||||
{
|
||||
bonds.Crushes.Clear();
|
||||
bonds.Crushes.AddRange(keep);
|
||||
}
|
||||
|
||||
if (bonds.PartnerId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!people.TryGetValue(bonds.PartnerId, out var partner)
|
||||
|| !CanRemainPair(catalog, rules, roster, years, person, partner, asOf))
|
||||
{
|
||||
var otherId = bonds.PartnerId;
|
||||
BreakPair(person, partner, asOf);
|
||||
events.Add(new AffinityEvent(person.Id, PairBreak, otherId));
|
||||
if (partner is not null)
|
||||
{
|
||||
events.Add(new AffinityEvent(partner.Id, PairBreak, person.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void GainCrushes(
|
||||
DefCatalog catalog,
|
||||
AffinityRulesDef rules,
|
||||
Roster roster,
|
||||
Dictionary<string, Person> people,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person person,
|
||||
DateTime asOf,
|
||||
List<AffinityEvent> events)
|
||||
{
|
||||
var bonds = EnsureBonds(person);
|
||||
if (bonds is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var threshold = rules.SympathyThreshold + TraitOffset(catalog, person, breakOffset: false);
|
||||
foreach (var (targetId, opinion) in person.Opinions)
|
||||
{
|
||||
if (opinion < threshold || !people.TryGetValue(targetId, out var target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!CanSympathy(catalog, rules, roster, years, person, target, asOf))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bonds.Crushes.Contains(targetId, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bonds.Crushes.Add(targetId);
|
||||
events.Add(new AffinityEvent(person.Id, Crush, targetId));
|
||||
if (person.IsStudent)
|
||||
{
|
||||
events.Add(new AffinityEvent(person.Id, Tease, targetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryFormPair(
|
||||
DefCatalog catalog,
|
||||
AffinityRulesDef rules,
|
||||
Roster roster,
|
||||
Dictionary<string, Person> people,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person person,
|
||||
DateTime asOf,
|
||||
List<AffinityEvent> events)
|
||||
{
|
||||
var bonds = person.Bonds;
|
||||
if (bonds is null || bonds.PartnerId is not null || !CooldownElapsed(bonds, asOf))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var threshold = rules.PairThreshold;
|
||||
foreach (var crushId in bonds.Crushes.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!people.TryGetValue(crushId, out var other) || other.Bonds is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (other.Bonds.PartnerId is not null || !CooldownElapsed(other.Bonds, asOf))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fromView = OpinionStore.Get(person, other.Id) ?? 0;
|
||||
var toView = OpinionStore.Get(other, person.Id) ?? 0;
|
||||
if (fromView < threshold || toView < threshold)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!CanPair(catalog, rules, roster, years, person, other, asOf))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bonds.PartnerId = other.Id;
|
||||
other.Bonds.PartnerId = person.Id;
|
||||
events.Add(new AffinityEvent(person.Id, Pair, other.Id));
|
||||
events.Add(new AffinityEvent(other.Id, Pair, person.Id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanSympathy(
|
||||
DefCatalog catalog,
|
||||
AffinityRulesDef rules,
|
||||
Roster roster,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person from,
|
||||
Person to,
|
||||
DateTime asOf)
|
||||
{
|
||||
if (from.Orientation is null || !Attracted(catalog, from, to))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rules.ExcludeFamily && OpinionStore.FamilyMemberIds(roster, from).Contains(to.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!RoleAllows(rules, from, to, pair: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (from.IsStudent && to.IsStudent && !StudentsClose(rules, years, from, to, asOf))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool CanPair(
|
||||
DefCatalog catalog,
|
||||
AffinityRulesDef rules,
|
||||
Roster roster,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person from,
|
||||
Person to,
|
||||
DateTime asOf)
|
||||
{
|
||||
if (!CanSympathy(catalog, rules, roster, years, from, to, asOf)
|
||||
|| !CanSympathy(catalog, rules, roster, years, to, from, asOf))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (from.AgeOn(asOf) < rules.PairMinAge || to.AgeOn(asOf) < rules.PairMinAge)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return RoleAllows(rules, from, to, pair: true) && RoleAllows(rules, to, from, pair: true);
|
||||
}
|
||||
|
||||
private static bool CanRemainPair(
|
||||
DefCatalog catalog,
|
||||
AffinityRulesDef rules,
|
||||
Roster roster,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person from,
|
||||
Person to,
|
||||
DateTime asOf)
|
||||
{
|
||||
if (!CanPair(catalog, rules, roster, years, from, to, asOf))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var floor = rules.PairBreakThreshold + TraitOffset(catalog, from, breakOffset: true);
|
||||
var view = OpinionStore.Get(from, to.Id) ?? 0;
|
||||
return view >= floor;
|
||||
}
|
||||
|
||||
private static bool Attracted(DefCatalog catalog, Person from, Person to)
|
||||
{
|
||||
if (from.Orientation is null || !catalog.Orientations.TryGetValue(from.Orientation, out var def) || def.Abstract)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return from.Female == to.Female ? def.SameGender : def.OppositeGender;
|
||||
}
|
||||
|
||||
private static bool RoleAllows(AffinityRulesDef rules, Person from, Person to, bool pair)
|
||||
{
|
||||
var fromRole = PrimaryRole(from);
|
||||
var toRole = PrimaryRole(to);
|
||||
if (fromRole is null || toRole is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var row in rules.RolePairs)
|
||||
{
|
||||
if (!row.From.Equals(fromRole, StringComparison.OrdinalIgnoreCase)
|
||||
|| !row.To.Equals(toRole, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return pair ? row.Pair : row.Sympathy;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? PrimaryRole(Person person)
|
||||
{
|
||||
if (person.IsStudent)
|
||||
{
|
||||
return PersonRoles.Student;
|
||||
}
|
||||
|
||||
if (person.IsStaff)
|
||||
{
|
||||
return PersonRoles.Staff;
|
||||
}
|
||||
|
||||
return person.IsParent ? PersonRoles.Parent : null;
|
||||
}
|
||||
|
||||
private static bool StudentsClose(
|
||||
AffinityRulesDef rules,
|
||||
IReadOnlyDictionary<string, SchoolClass> years,
|
||||
Person from,
|
||||
Person to,
|
||||
DateTime asOf)
|
||||
{
|
||||
if (Math.Abs(from.AgeOn(asOf) - to.AgeOn(asOf)) <= rules.StudentAgeDeltaYears)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!rules.StudentAdjacentYear)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fromYear = YearOf(years, from);
|
||||
var toYear = YearOf(years, to);
|
||||
return fromYear is { } left && toYear is { } right && Math.Abs(left - right) == 1;
|
||||
}
|
||||
|
||||
private static int? YearOf(IReadOnlyDictionary<string, SchoolClass> years, Person person) =>
|
||||
person.ClassId is { } classId && years.TryGetValue(classId, out var schoolClass) ? schoolClass.Year : null;
|
||||
|
||||
private static int TraitOffset(DefCatalog catalog, Person person, bool breakOffset)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var traitId in person.Traits)
|
||||
{
|
||||
if (!catalog.Traits.TryGetValue(traitId, out var trait))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
total += breakOffset ? trait.AffinityBreakOffset : trait.AffinityThresholdOffset;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static PersonBonds? EnsureBonds(Person person)
|
||||
{
|
||||
if (person.Orientation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return person.Bonds ??= new PersonBonds();
|
||||
}
|
||||
|
||||
private static bool CooldownElapsed(PersonBonds bonds, DateTime asOf)
|
||||
{
|
||||
if (bonds.PairEndedOn is not { } ended)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return asOf.Date > DateTime.SpecifyKind(ended, DateTimeKind.Utc).Date;
|
||||
}
|
||||
|
||||
private static void BreakPair(Person left, Person? right, DateTime asOf)
|
||||
{
|
||||
var leftBonds = EnsureBonds(left);
|
||||
if (leftBonds is not null)
|
||||
{
|
||||
leftBonds.PartnerId = null;
|
||||
leftBonds.PairEndedOn = asOf;
|
||||
}
|
||||
|
||||
if (right is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rightBonds = EnsureBonds(right);
|
||||
if (rightBonds is not null)
|
||||
{
|
||||
rightBonds.PartnerId = null;
|
||||
rightBonds.PairEndedOn = asOf;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,10 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
||||
applicants.Add(next);
|
||||
}
|
||||
|
||||
return new ApplicantPool(current.Week, nextIndex, applicants);
|
||||
return OrientationGenerator.AssignPool(
|
||||
catalog,
|
||||
new ApplicantPool(current.Week, nextIndex, applicants),
|
||||
schoolSeed);
|
||||
}
|
||||
|
||||
private static StaffingDef RequireRules(DefCatalog catalog) =>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>
|
||||
/// Writes <see cref="Person.Orientation"/> only when the catalog has orientations. Vanilla
|
||||
/// catalogs skip this, so the field stays null and is omitted from people.json.
|
||||
/// </summary>
|
||||
public static class OrientationGenerator
|
||||
{
|
||||
public static Roster Assign(DefCatalog catalog, Roster roster, int schoolSeed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
|
||||
if (catalog.Orientations.Count == 0)
|
||||
{
|
||||
return roster;
|
||||
}
|
||||
|
||||
var people = new Person[roster.People.Count];
|
||||
var changed = false;
|
||||
for (var i = 0; i < roster.People.Count; i++)
|
||||
{
|
||||
var person = roster.People[i];
|
||||
var updated = AssignIfMissing(catalog, person, schoolSeed);
|
||||
people[i] = updated;
|
||||
changed |= !ReferenceEquals(updated, person);
|
||||
}
|
||||
|
||||
return changed ? roster with { People = people } : roster;
|
||||
}
|
||||
|
||||
public static ApplicantPool AssignPool(DefCatalog catalog, ApplicantPool pool, int schoolSeed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(pool);
|
||||
|
||||
if (catalog.Orientations.Count == 0 || pool.Applicants.Count == 0)
|
||||
{
|
||||
return pool;
|
||||
}
|
||||
|
||||
var applicants = new Applicant[pool.Applicants.Count];
|
||||
var changed = false;
|
||||
for (var i = 0; i < pool.Applicants.Count; i++)
|
||||
{
|
||||
var applicant = pool.Applicants[i];
|
||||
var person = AssignIfMissing(catalog, applicant.Person, schoolSeed);
|
||||
changed |= !ReferenceEquals(person, applicant.Person);
|
||||
applicants[i] = ReferenceEquals(person, applicant.Person) ? applicant : applicant with { Person = person };
|
||||
}
|
||||
|
||||
return changed ? pool with { Applicants = applicants } : pool;
|
||||
}
|
||||
|
||||
public static Person AssignIfMissing(DefCatalog catalog, Person person, int schoolSeed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(person);
|
||||
|
||||
if (catalog.Orientations.Count == 0 || person.Orientation is not null)
|
||||
{
|
||||
return person;
|
||||
}
|
||||
|
||||
var id = Pick(catalog, person.Id, schoolSeed);
|
||||
if (id is null)
|
||||
{
|
||||
return person;
|
||||
}
|
||||
|
||||
return person with
|
||||
{
|
||||
Orientation = id,
|
||||
Bonds = person.Bonds ?? new PersonBonds(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? Pick(DefCatalog catalog, string personId, int schoolSeed)
|
||||
{
|
||||
var options = catalog.Orientations.Values
|
||||
.Where(def => !def.Abstract)
|
||||
.OrderBy(def => def.DefName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (options.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
foreach (var option in options)
|
||||
{
|
||||
total += option.Weight;
|
||||
}
|
||||
|
||||
if (total <= 0)
|
||||
{
|
||||
return options[0].DefName;
|
||||
}
|
||||
|
||||
var rng = new Random(Seed.Mix(schoolSeed, personId, dayNumber: 0, Seed.OrientationSalt));
|
||||
var roll = rng.Next(total);
|
||||
foreach (var option in options)
|
||||
{
|
||||
roll -= option.Weight;
|
||||
if (roll < 0)
|
||||
{
|
||||
return option.DefName;
|
||||
}
|
||||
}
|
||||
|
||||
return options[^1].DefName;
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,30 @@ public sealed record Person
|
||||
/// <summary>What this person thinks of others, A→B. Sparse; zero is not stored.</summary>
|
||||
public IReadOnlyDictionary<string, int> Opinions { get; init; } = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// OrientationDef name. Null when the catalog has no orientations (vanilla). Omitted from JSON.
|
||||
/// </summary>
|
||||
public string? Orientation { get; init; }
|
||||
|
||||
/// <summary>Crushes and the one pair. Null without an orientation pack. Mutable overlay.</summary>
|
||||
public PersonBonds? Bonds { get; set; }
|
||||
|
||||
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-way crushes and at most one pair. Lives on the person when a pack shipped orientations.
|
||||
/// Mutated in place the same way opinions are.
|
||||
/// </summary>
|
||||
public sealed class PersonBonds
|
||||
{
|
||||
public List<string> Crushes { get; set; } = [];
|
||||
|
||||
public string? PartnerId { get; set; }
|
||||
|
||||
public DateTime? PairEndedOn { get; set; }
|
||||
}
|
||||
|
||||
public sealed record PersonName(
|
||||
string Given,
|
||||
string Surname,
|
||||
|
||||
@@ -45,7 +45,9 @@ public static class RosterGenerator
|
||||
|
||||
var classes = FillClasses(demand.Classes, people);
|
||||
var roster = LockerAssigner.Apply(catalog, map, new Roster(people, families, classes));
|
||||
return OpinionGenerator.SeedFamily(catalog, roster);
|
||||
roster = OrientationGenerator.Assign(catalog, OpinionGenerator.SeedFamily(catalog, roster), schoolSeed);
|
||||
Affinity.Refresh(catalog, roster, when);
|
||||
return roster;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -17,6 +17,7 @@ public static class Seed
|
||||
public const int NativeLanguageSalt = 9;
|
||||
public const int ClimatePresetSalt = 10;
|
||||
public const int ApparelSalt = 11;
|
||||
public const int OrientationSalt = 12;
|
||||
|
||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||
|
||||
@@ -148,7 +148,9 @@ public static class YearlyIntake
|
||||
|
||||
var people = remainingPeople.Values.ToList();
|
||||
var filled = AttachPupils(classes, people);
|
||||
return new Roster(people, families, filled);
|
||||
var next = OrientationGenerator.Assign(catalog, new Roster(people, families, filled), schoolSeed);
|
||||
Affinity.Refresh(catalog, next, when);
|
||||
return next;
|
||||
}
|
||||
|
||||
private static void GrantPromotedSkills(
|
||||
|
||||
@@ -126,7 +126,8 @@ internal sealed record CatalogResponse(
|
||||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||||
DayFrameResponse? DayFrame,
|
||||
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||||
MapLayout DefaultMap)
|
||||
MapLayout DefaultMap,
|
||||
IReadOnlyList<TopicInfoResponse> Topics)
|
||||
{
|
||||
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
|
||||
new(
|
||||
@@ -139,7 +140,8 @@ internal sealed record CatalogResponse(
|
||||
PlaceableSubjects(catalog, locale),
|
||||
MapDayFrame(catalog, locale),
|
||||
PlaceableHolidays(catalog, locale),
|
||||
map);
|
||||
map,
|
||||
PlaceableTopics(catalog, locale));
|
||||
|
||||
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
||||
where T : Def =>
|
||||
@@ -227,6 +229,16 @@ internal sealed record CatalogResponse(
|
||||
def.Start,
|
||||
def.End))
|
||||
.ToArray();
|
||||
|
||||
private static IReadOnlyList<TopicInfoResponse> PlaceableTopics(DefCatalog catalog, string locale) =>
|
||||
catalog.Topics.Values
|
||||
.Where(def => !def.Abstract)
|
||||
.OrderBy(def => def.DefName, StringComparer.Ordinal)
|
||||
.Select(def => new TopicInfoResponse(
|
||||
def.DefName,
|
||||
catalog.Label(locale, def),
|
||||
def.Tags.ToArray()))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
|
||||
@@ -270,3 +282,5 @@ internal sealed record DayFrameResponse(
|
||||
int LongBreakMinutes);
|
||||
|
||||
internal sealed record HolidayInfoResponse(string DefName, string Label, MonthDay Start, MonthDay End);
|
||||
|
||||
internal sealed record TopicInfoResponse(string DefName, string Label, IReadOnlyList<string> Tags);
|
||||
|
||||
@@ -82,7 +82,8 @@ internal sealed record PersonCardResponse(
|
||||
bool HasCustom = false,
|
||||
bool HasFullBody = false,
|
||||
string? CustomPortraitPrompt = null,
|
||||
PersonConnectionsResponse? Connections = null);
|
||||
PersonConnectionsResponse? Connections = null,
|
||||
DefLabelResponse? Orientation = null);
|
||||
|
||||
internal sealed record WornItemResponse(
|
||||
string DefName,
|
||||
@@ -131,7 +132,10 @@ internal sealed record PersonConnectionsResponse(
|
||||
PersonFamilyResponse Family,
|
||||
IReadOnlyList<PersonOpinionLinkResponse> Friends,
|
||||
IReadOnlyList<PersonOpinionLinkResponse> Enemies,
|
||||
IReadOnlyList<PersonOpinionLinkResponse> Others);
|
||||
IReadOnlyList<PersonOpinionLinkResponse> Others,
|
||||
IReadOnlyList<PersonOpinionLinkResponse>? Crushes = null,
|
||||
IReadOnlyList<PersonOpinionLinkResponse>? Admirers = null,
|
||||
PersonRelResponse? Pair = null);
|
||||
|
||||
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ internal static class PersonCardReader
|
||||
person.LockerRoomId is not null
|
||||
|| person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)),
|
||||
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)),
|
||||
Connections: Connections(roster, person, catalog, locale));
|
||||
Connections: Connections(roster, person, catalog, locale),
|
||||
Orientation: OrientationOf(person, catalog, locale));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
|
||||
@@ -449,7 +450,101 @@ internal static class PersonCardReader
|
||||
var top = catalog?.BehaviorRules?.OpinionTopCount ?? 5;
|
||||
var friends = others.Where(row => row.Opinion > 0).Take(top).ToArray();
|
||||
var enemies = others.Where(row => row.Opinion < 0).Take(top).ToArray();
|
||||
return new PersonConnectionsResponse(family, friends, enemies, others);
|
||||
var crushes = CrushesOf(person, people, catalog, locale);
|
||||
var admirers = AdmirersOf(roster, person, people, catalog, locale);
|
||||
var pair = PairOf(person, people, catalog, locale);
|
||||
return new PersonConnectionsResponse(family, friends, enemies, others, crushes, admirers, pair);
|
||||
}
|
||||
|
||||
private static DefLabelResponse? OrientationOf(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
if (person.Orientation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var label = person.Orientation;
|
||||
if (catalog is not null && catalog.Orientations.TryGetValue(person.Orientation, out var def))
|
||||
{
|
||||
label = catalog.Label(locale, def);
|
||||
}
|
||||
|
||||
return new DefLabelResponse(person.Orientation, label);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PersonOpinionLinkResponse> CrushesOf(
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
if (person.Bonds is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rows = new List<PersonOpinionLinkResponse>();
|
||||
foreach (var id in person.Bonds.Crushes)
|
||||
{
|
||||
if (!people.TryGetValue(id, out var target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var opinion = OpinionStore.Get(person, id) ?? 0;
|
||||
rows.Add(Link(target, opinion, catalog, locale));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PersonOpinionLinkResponse> AdmirersOf(
|
||||
Roster roster,
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
var rows = new List<PersonOpinionLinkResponse>();
|
||||
foreach (var other in roster.People)
|
||||
{
|
||||
if (other.Id.Equals(person.Id, StringComparison.Ordinal) || other.Bonds is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!other.Bonds.Crushes.Contains(person.Id, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var opinion = OpinionStore.Get(other, person.Id) ?? 0;
|
||||
rows.Add(Link(other, opinion, catalog, locale));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static PersonRelResponse? PairOf(
|
||||
Person person,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
if (person.Bonds?.PartnerId is not { } partnerId || !people.TryGetValue(partnerId, out var partner))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int? opinion = null;
|
||||
string? label = null;
|
||||
if (person.Opinions.TryGetValue(partnerId, out var value))
|
||||
{
|
||||
opinion = value;
|
||||
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
|
||||
}
|
||||
|
||||
return new PersonRelResponse(partner.Id, partner.Name.Full, partner.Female, opinion, label);
|
||||
}
|
||||
|
||||
private static PersonFamilyResponse FamilyWithOpinions(
|
||||
|
||||
@@ -996,6 +996,17 @@ internal sealed class SchoolWorker
|
||||
generated = true;
|
||||
}
|
||||
|
||||
var assigned = OrientationGenerator.Assign(catalog, roster, seed);
|
||||
generated |= !ReferenceEquals(assigned, roster);
|
||||
roster = assigned;
|
||||
var assignedPool = OrientationGenerator.AssignPool(catalog, applicants, seed);
|
||||
generated |= !ReferenceEquals(assignedPool, applicants);
|
||||
applicants = assignedPool;
|
||||
if (Affinity.Refresh(catalog, roster, school.Clock.Time).Count > 0)
|
||||
{
|
||||
generated = true;
|
||||
}
|
||||
|
||||
roster = LockerAssigner.Apply(catalog, map, roster);
|
||||
|
||||
if (!RosterFit.Matches(roster, demand))
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Romance pack
|
||||
|
||||
A content pack, not a sample folder. Tick it in create. Vanilla schools stay without
|
||||
orientation, crushes, pairs, or these topics.
|
||||
|
||||
`example` is the layout sample; this pack is the living one. Do not copy content into
|
||||
`example`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
romance/
|
||||
pack.jsonc # version + requires: ["core"]
|
||||
localizations/{ru,en}.jsonc
|
||||
defs/orientations/ # OrientationDef + generator weights
|
||||
defs/affinity/rules.jsonc # AffinityRulesDef singleton — numbers, not pack ids in code
|
||||
defs/traits/traits.jsonc # crush-prone / jealous via TraitDef fields
|
||||
defs/topics/romance.jsonc # topics that exist only with this pack
|
||||
patches/topic-appearance.jsonc
|
||||
README.md
|
||||
```
|
||||
|
||||
Core already picks every `TopicDef` in the catalog for `Chat`. New topics join that pool;
|
||||
`Chat` itself is not rewritten. The appearance topic gets a `romance` tag so crush-prone
|
||||
people lean toward it — that is a patch, not a copy of core.
|
||||
|
||||
Without this pack the catalog has no `OrientationDef` and no `AffinityRulesDef`. The
|
||||
generator then writes no orientation field, and the affinity overlay does not run.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"defName": "AffinityRules",
|
||||
"sympathyThreshold": 50,
|
||||
"pairThreshold": 70,
|
||||
"pairBreakThreshold": 20,
|
||||
"pairMinAge": 18,
|
||||
"studentAgeDeltaYears": 2,
|
||||
"studentAdjacentYear": true,
|
||||
"excludeFamily": true,
|
||||
"rebuffOpinionShift": -8,
|
||||
"rolePairs": [
|
||||
{ "from": "student", "to": "student", "sympathy": true, "pair": true },
|
||||
{ "from": "student", "to": "staff", "sympathy": true, "pair": false },
|
||||
{ "from": "staff", "to": "student", "sympathy": false, "pair": false },
|
||||
{ "from": "staff", "to": "staff", "sympathy": true, "pair": true },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"defName": "Heterosexual",
|
||||
"weight": 70,
|
||||
"oppositeGender": true,
|
||||
},
|
||||
{
|
||||
"defName": "Bisexual",
|
||||
"weight": 20,
|
||||
"sameGender": true,
|
||||
"oppositeGender": true,
|
||||
},
|
||||
{
|
||||
"defName": "Homosexual",
|
||||
"weight": 10,
|
||||
"sameGender": true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"defName": "TopicCrush",
|
||||
"tags": ["romance", "gossip"],
|
||||
"roles": ["student"],
|
||||
"age": { "min": 11, "max": 18 },
|
||||
"opinionShift": 2,
|
||||
},
|
||||
{
|
||||
"defName": "TopicCouple",
|
||||
"tags": ["romance"],
|
||||
"roles": ["student", "staff"],
|
||||
"age": { "min": 18 },
|
||||
"opinionShift": 3,
|
||||
},
|
||||
{
|
||||
"defName": "TopicTease",
|
||||
"tags": ["romance", "gossip"],
|
||||
"roles": ["student"],
|
||||
"age": { "min": 7, "max": 18 },
|
||||
"opinionShift": 1,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"defName": "CrushProne",
|
||||
"weight": 5,
|
||||
"roles": ["student", "staff"],
|
||||
"affinityThresholdOffset": -15,
|
||||
"talkTagWeights": [{ "tag": "romance", "weight": 3 }],
|
||||
},
|
||||
{
|
||||
"defName": "Jealous",
|
||||
"weight": 4,
|
||||
"roles": ["student", "staff"],
|
||||
"age": { "min": 14 },
|
||||
"affinityBreakOffset": 15,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"romance": "Romance",
|
||||
"Heterosexual": "heterosexual",
|
||||
"Bisexual": "bisexual",
|
||||
"Homosexual": "homosexual",
|
||||
"AffinityRules": "Affinity rules",
|
||||
"CrushProne": "crush-prone",
|
||||
"Jealous": "jealous",
|
||||
"TopicCrush": "who likes whom",
|
||||
"TopicCouple": "couples",
|
||||
"TopicTease": "teasing",
|
||||
"AffinityCrush": "a crush started",
|
||||
"AffinityPair": "became a couple",
|
||||
"AffinityPairBreak": "the couple split",
|
||||
"AffinityTease": "teasing in class",
|
||||
"AffinityRebuff": "turned down in talk",
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"romance": "Романтика",
|
||||
"Heterosexual": "гетеро",
|
||||
"Bisexual": "би",
|
||||
"Homosexual": "гомо",
|
||||
"AffinityRules": "Правила симпатии",
|
||||
"CrushProne": "влюбчивый",
|
||||
"Jealous": "ревнивый",
|
||||
"TopicCrush": "кто кому нравится",
|
||||
"TopicCouple": "пары",
|
||||
"TopicTease": "дразнилки",
|
||||
"AffinityCrush": "появилась симпатия",
|
||||
"AffinityPair": "стали парой",
|
||||
"AffinityPairBreak": "пара распалась",
|
||||
"AffinityTease": "дразнилки в классе",
|
||||
"AffinityRebuff": "разговор-отказ",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"requires": ["core"],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"target": "TopicAppearance",
|
||||
"ops": [
|
||||
{ "op": "add", "path": "/tags/-", "value": "romance" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the catalog's affinity overlay after talk and morning opinion shifts.
|
||||
/// No-ops when the catalog has no affinity rules.
|
||||
/// </summary>
|
||||
internal static class AffinitySystem
|
||||
{
|
||||
public static bool Apply(School school, IReadOnlyList<string>? justTalked = null)
|
||||
{
|
||||
if (school.Catalog is null || school.Roster is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var events = new List<AffinityEvent>();
|
||||
events.AddRange(Affinity.Refresh(school.Catalog, school.Roster, school.Clock.Time));
|
||||
if (justTalked is { Count: > 0 })
|
||||
{
|
||||
events.AddRange(Affinity.RebuffTalk(school.Catalog, school.Roster, justTalked, school.Clock.Time));
|
||||
if (events.Count > 0)
|
||||
{
|
||||
events.AddRange(Affinity.Refresh(school.Catalog, school.Roster, school.Clock.Time));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var row in events)
|
||||
{
|
||||
school.AppendDayLog(new PersonLogEvent(row.PersonId, school.Clock.Time, row.Type, row.OtherId));
|
||||
}
|
||||
|
||||
return events.Count > 0;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
if (ThingDef is null)
|
||||
{
|
||||
return Type;
|
||||
var emptyKey = TypeToLocaleKey(Type);
|
||||
return catalog.HasText(locale, emptyKey) ? catalog.Text(locale, emptyKey) : Type;
|
||||
}
|
||||
|
||||
if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal))
|
||||
@@ -85,8 +86,35 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
|
||||
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic);
|
||||
}
|
||||
|
||||
var localeKey = TypeToLocaleKey(Type);
|
||||
if (catalog.HasText(locale, localeKey))
|
||||
{
|
||||
var text = catalog.Text(locale, localeKey);
|
||||
return string.Format(CultureInfo.InvariantCulture, text, ThingDef);
|
||||
}
|
||||
|
||||
return Type;
|
||||
}
|
||||
|
||||
private static string TypeToLocaleKey(string type)
|
||||
{
|
||||
var chars = new char[type.Length];
|
||||
var length = 0;
|
||||
var upper = true;
|
||||
foreach (var character in type)
|
||||
{
|
||||
if (character == '-')
|
||||
{
|
||||
upper = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
chars[length++] = upper ? char.ToUpperInvariant(character) : character;
|
||||
upper = false;
|
||||
}
|
||||
|
||||
return new string(chars, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize);
|
||||
|
||||
@@ -360,7 +360,8 @@ public sealed class School : IDisposable
|
||||
PresenceSystem.Enqueue(this, id);
|
||||
}
|
||||
|
||||
foreach (var id in TalkCircleSystem.Apply(this, gameMinutes))
|
||||
var talked = TalkCircleSystem.Apply(this, gameMinutes);
|
||||
foreach (var id in talked)
|
||||
{
|
||||
PresenceSystem.Enqueue(this, id);
|
||||
}
|
||||
@@ -376,6 +377,7 @@ public sealed class School : IDisposable
|
||||
PresenceSystem.DrainDecisions(this);
|
||||
LessonLearningSystem.Apply(this, gameMinutes);
|
||||
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
|
||||
peopleChanged |= AffinitySystem.Apply(this, talked);
|
||||
}
|
||||
|
||||
return peopleChanged;
|
||||
|
||||
Reference in New Issue
Block a user