Merge branch 'phase/41-opinions'

# Conflicts:
#	docs/phases/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 07:58:05 +03:00
25 changed files with 1021 additions and 28 deletions
+21 -2
View File
@@ -81,7 +81,8 @@ internal sealed record PersonCardResponse(
bool HasAvatar = false,
bool HasCustom = false,
bool HasFullBody = false,
string? CustomPortraitPrompt = null);
string? CustomPortraitPrompt = null,
PersonConnectionsResponse? Connections = null);
internal sealed record WornItemResponse(
string DefName,
@@ -112,7 +113,25 @@ internal sealed record PersonFamilyResponse(
IReadOnlyList<PersonRelResponse> Siblings,
IReadOnlyList<PersonRelResponse> Partners);
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
internal sealed record PersonRelResponse(
string Id,
string FullName,
bool Female,
int? Opinion = null,
string? OpinionLabel = null);
internal sealed record PersonOpinionLinkResponse(
string Id,
string FullName,
bool Female,
int Opinion,
string OpinionLabel);
internal sealed record PersonConnectionsResponse(
PersonFamilyResponse Family,
IReadOnlyList<PersonOpinionLinkResponse> Friends,
IReadOnlyList<PersonOpinionLinkResponse> Enemies,
IReadOnlyList<PersonOpinionLinkResponse> Others);
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
+111 -3
View File
@@ -88,7 +88,8 @@ internal static class PersonCardReader
Capacity(person, skills, catalog),
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)));
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)),
Connections: Connections(roster, person, catalog, locale));
}
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -376,8 +377,8 @@ internal static class PersonCardReader
}
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var inParents = family.ParentIds.Contains(person.Id, StringComparer.Ordinal);
var inChildren = family.ChildIds.Contains(person.Id, StringComparer.Ordinal);
var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse(
inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [],
inParents ? Relatives(family.ChildIds, people, except: person.Id) : [],
@@ -385,6 +386,19 @@ internal static class PersonCardReader
inParents ? Relatives(family.ParentIds, people, except: person.Id) : []);
}
private static bool InFamily(IReadOnlyList<string> ids, string id)
{
foreach (var candidate in ids)
{
if (candidate.Equals(id, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static IReadOnlyList<PersonRelResponse> Relatives(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
@@ -403,4 +417,98 @@ internal static class PersonCardReader
return rows;
}
private static PersonConnectionsResponse Connections(
Roster roster,
Person person,
DefCatalog? catalog,
string locale)
{
var familyIds = OpinionStore.FamilyMemberIds(roster, person);
var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal);
var family = FamilyWithOpinions(roster, person, people, catalog, locale);
var others = new List<PersonOpinionLinkResponse>();
foreach (var (targetId, value) in person.Opinions)
{
if (familyIds.Contains(targetId) || !people.TryGetValue(targetId, out var target))
{
continue;
}
others.Add(Link(target, value, catalog, locale));
}
others.Sort((left, right) =>
{
var byAbs = Math.Abs(right.Opinion).CompareTo(Math.Abs(left.Opinion));
return byAbs != 0
? byAbs
: string.Compare(left.FullName, right.FullName, StringComparison.Ordinal);
});
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);
}
private static PersonFamilyResponse FamilyWithOpinions(
Roster roster,
Person person,
IReadOnlyDictionary<string, Person> people,
DefCatalog? catalog,
string locale)
{
var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal));
if (family is null)
{
return new PersonFamilyResponse([], [], [], []);
}
var inParents = InFamily(family.ParentIds, person.Id);
var inChildren = InFamily(family.ChildIds, person.Id);
return new PersonFamilyResponse(
inChildren ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : [],
inParents ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
inChildren ? RelativesWithOpinions(family.ChildIds, people, person, except: person.Id, catalog, locale) : [],
inParents ? RelativesWithOpinions(family.ParentIds, people, person, except: person.Id, catalog, locale) : []);
}
private static IReadOnlyList<PersonRelResponse> RelativesWithOpinions(
IReadOnlyList<string> ids,
IReadOnlyDictionary<string, Person> people,
Person person,
string except,
DefCatalog? catalog,
string locale)
{
var rows = new List<PersonRelResponse>();
foreach (var id in ids)
{
if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative))
{
continue;
}
int? opinion = null;
string? label = null;
if (person.Opinions.TryGetValue(id, out var value))
{
opinion = value;
label = catalog is null ? OpinionLabels.BandId(null, value) : OpinionLabels.Label(catalog, locale, value);
}
rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female, opinion, label));
}
return rows;
}
private static PersonOpinionLinkResponse Link(Person target, int opinion, DefCatalog? catalog, string locale) =>
new(
target.Id,
target.Name.Full,
target.Female,
opinion,
catalog is null ? OpinionLabels.BandId(null, opinion) : OpinionLabels.Label(catalog, locale, opinion));
}
+6
View File
@@ -979,6 +979,12 @@ internal sealed class SchoolWorker
RequireKnownApparel(catalog, roster, applicants);
}
if (OpinionGenerator.NeedsFamilyOpinions(roster))
{
roster = OpinionGenerator.SeedFamily(catalog, roster);
generated = true;
}
roster = LockerAssigner.Apply(catalog, map, roster);
if (!RosterFit.Matches(roster, demand))
@@ -46,4 +46,20 @@
"heavyOuterAboveC": 15,
"apparelGoalWeight": 6,
"changeClothesMinutes": 5,
// Opinion starts and morning drift (slice 9 phase 41).
"opinionParentToChildStart": 85,
"opinionChildToParentStart": 75,
"opinionSiblingStart": 45,
"opinionPartnerStart": 65,
"opinionDriftPerMorning": 3,
"opinionTopCount": 5,
"opinionBands": [
{ "min": 70, "id": "OpinionCloseFriend" },
{ "min": 40, "id": "OpinionFriend" },
{ "min": 20, "id": "OpinionPleasant" },
{ "min": 1, "id": "OpinionAcquaintance" },
{ "min": -1, "id": "OpinionStrained" },
{ "min": -40, "id": "OpinionDislike" },
{ "min": -100, "id": "OpinionEnemy" },
],
}
@@ -180,6 +180,13 @@
"ApparelConditionWorn": "worn",
"ApparelConditionTorn": "torn",
"ApparelConditionRags": "in rags",
"OpinionCloseFriend": "close friends",
"OpinionFriend": "friends",
"OpinionPleasant": "pleasant",
"OpinionAcquaintance": "acquaintances",
"OpinionStrained": "strained",
"OpinionDislike": "dislike",
"OpinionEnemy": "enemies",
"ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}",
@@ -180,6 +180,13 @@
"ApparelConditionWorn": "поношенная",
"ApparelConditionTorn": "порванная",
"ApparelConditionRags": "висит лохмотьями",
"OpinionCloseFriend": "близкие друзья",
"OpinionFriend": "друзья",
"OpinionPleasant": "приятели",
"OpinionAcquaintance": "знакомые",
"OpinionStrained": "натянуто",
"OpinionDislike": "неприязнь",
"OpinionEnemy": "враги",
"ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}",