diff --git a/docs/phases/41-opinions.md b/docs/phases/41-opinions.md index 5984441..f785840 100644 --- a/docs/phases/41-opinions.md +++ b/docs/phases/41-opinions.md @@ -12,25 +12,25 @@ ## Задачи -- [ ] Мнение A→B: −100…100, несимметрично, ноль не хранить -- [ ] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам -- [ ] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных -- [ ] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю; +- [x] Мнение A→B: −100…100, несимметрично, ноль не хранить +- [x] Пороги и подписи в `BehaviorDef`; клиент рисует присланную подпись, не считает сам +- [x] Генератор ставит семейные старты (родители, сиблинги, партнёры семьи) из данных +- [x] Каждое рабочее утро (и skip) ненулевые мнения слегка дрейфуют к семейному базису или к нулю; дошли до нуля — запись выкинуть -- [ ] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи +- [x] `people.json` пишет мнение при сдвиге; старый сейв без поля — все нули кроме семьи после первой загрузки-переписи -- [ ] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым -- [ ] Игрок с вкладки ничего не меняет -- [ ] `docs/protocol.md` — HTTP карточки +- [x] Вкладка карточки **Связи**: семья с числами, топ друзей/врагов, поиск по ненулевым +- [x] Игрок с вкладки ничего не меняет +- [x] `docs/protocol.md` — HTTP карточки ## Тесты, без которых фаза не закрыта -- [ ] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи -- [ ] A→B и B→A могут различаться -- [ ] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось) +- [x] Родитель и ребёнок после генерации имеют плюс в обе стороны; чужие одноклассники — нет записи +- [x] A→B и B→A могут различаться +- [x] После нескольких утренних дрейфов без разговоров мнение одноклассников (если появилось) ближе к нулю, семейное — к старту, не к нулю -- [ ] Карточка без отдельного запроса «всех пар школы»: только этот человек -- [ ] Клиентский тест: вкладка «Связи» не монтирует лог +- [x] Карточка без отдельного запроса «всех пар школы»: только этот человек +- [x] Клиентский тест: вкладка «Связи» не монтирует лог ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 9599e44..370eab4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -269,6 +269,10 @@ person. `customPortraitPrompt` is the last saved user prompt for the custom vari They are filled on the HTTP thread after the worker returns the card; generation does not happen on this request. +`connections` is the **Связи** tab: family links with `opinion` / `opinionLabel` from catalog bands, +plus `friends`, `enemies` and `others` (every non-family non-zero pair for this person only). +The client does not compute thresholds. There is no school-wide opinions endpoint. + ```json { "id": "f0.c0", @@ -321,7 +325,34 @@ not happen on this request. "hasAvatar": false, "hasCustom": false, "hasFullBody": false, - "customPortraitPrompt": null + "customPortraitPrompt": null, + "connections": { + "family": { + "parents": [ + { + "id": "f0.p1", + "fullName": "Иванова Ольга Михайловна", + "female": true, + "opinion": 75, + "opinionLabel": "близкие друзья" + } + ], + "children": [], + "siblings": [ + { + "id": "f0.c1", + "fullName": "Иванов Кирилл Петрович", + "female": false, + "opinion": 45, + "opinionLabel": "друзья" + } + ], + "partners": [] + }, + "friends": [], + "enemies": [], + "others": [] + } } ``` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 2018591..08ebe73 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -178,7 +178,13 @@ const ru = { peopleTabApparel: 'Одежда', peopleTabCarry: 'Ноша', peopleTabNow: 'Сейчас', + peopleTabConnections: 'Связи', peopleTabPortrait: 'Портрет', + peopleConnectionsFriends: 'Друзья', + peopleConnectionsEnemies: 'Враги', + peopleConnectionsSearch: 'Поиск по связям', + peopleConnectionsEmpty: 'Нет других связей.', + peopleOpinionValue: '{label} ({value})', peopleAtHome: 'дома', peopleApparelFit: 'Уместность: {value}', peopleLockerYes: 'Есть шкафчик', @@ -507,7 +513,13 @@ const en: Messages = { peopleTabApparel: 'Clothes', peopleTabCarry: 'Carried', peopleTabNow: 'Now', + peopleTabConnections: 'Connections', peopleTabPortrait: 'Portrait', + peopleConnectionsFriends: 'Friends', + peopleConnectionsEnemies: 'Enemies', + peopleConnectionsSearch: 'Search connections', + peopleConnectionsEmpty: 'No other connections.', + peopleOpinionValue: '{label} ({value})', peopleAtHome: 'home', peopleApparelFit: 'Fit: {value}', peopleLockerYes: 'Has a locker', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 9601e68..f43e01c 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -262,6 +262,28 @@ export interface PersonRel { readonly id: string; readonly fullName: string; readonly female: boolean; + readonly opinion?: number | null; + readonly opinionLabel?: string | null; +} + +export interface PersonOpinionLink { + readonly id: string; + readonly fullName: string; + readonly female: boolean; + readonly opinion: number; + readonly opinionLabel: string; +} + +export interface PersonConnections { + readonly family: { + readonly parents: readonly PersonRel[]; + readonly children: readonly PersonRel[]; + readonly siblings: readonly PersonRel[]; + readonly partners: readonly PersonRel[]; + }; + readonly friends: readonly PersonOpinionLink[]; + readonly enemies: readonly PersonOpinionLink[]; + readonly others: readonly PersonOpinionLink[]; } export interface PersonCard { @@ -301,6 +323,7 @@ export interface PersonCard { readonly hasCustom: boolean; readonly hasFullBody: boolean; readonly customPortraitPrompt: string | null; + readonly connections: PersonConnections | null; } export interface WornItem { diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index c74dfe5..d067d83 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -78,6 +78,7 @@ function personCard(): PersonCard { hasCustom: false, hasFullBody: false, customPortraitPrompt: null, + connections: null, }; } diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 6746154..81517ad 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -64,6 +64,25 @@ function card(overrides: Partial = {}): PersonCard { hasCustom: false, hasFullBody: false, customPortraitPrompt: null, + connections: { + family: { + parents: [ + { + id: 'f0.p0', + fullName: 'Иванова Ольга', + female: true, + opinion: 75, + opinionLabel: 'близкие друзья', + }, + ], + children: [], + siblings: [], + partners: [], + }, + friends: [], + enemies: [], + others: [], + }, ...overrides, }; } @@ -261,6 +280,17 @@ describe('renderPersonCard', () => { expect(root.querySelector('.people__log-tools')).toBeNull(); }); + it('does not mount the log table on the connections tab', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card(), () => {}, { tab: 'connections', log: logPage() }); + + expect(root.querySelector('[data-card-tab="connections"]')?.hasAttribute('hidden')).toBe(false); + expect(root.textContent).toContain('близкие друзья'); + expect(root.querySelector('.people__log')).toBeNull(); + expect(root.querySelector('.people__log-tools')).toBeNull(); + }); + it('pages the log on the now tab', () => { setLocale('ru'); const onLogPage = vi.fn(); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 8fbbc72..0d62dae 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -4,13 +4,14 @@ import type { PersonLogDir, PersonLogPage, PersonLogQuery, + PersonOpinionLink, PersonRel, PersonRole, } from '../net/api.ts'; import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts'; import { formatGameTimeOfDay } from '../format/gameTime.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; -import { el } from './dom.ts'; +import { clear, el } from './dom.ts'; const ROLE_KEYS: Record = { student: 'peopleRoleStudent', @@ -18,7 +19,7 @@ const ROLE_KEYS: Record = { parent: 'peopleRoleParent', }; -export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'portrait'; +export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait'; export interface RenderPersonCardOptions { readonly place?: string; @@ -110,6 +111,7 @@ export function renderPersonCard( apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }), carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }), now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }), + connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }), portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }), }; @@ -118,6 +120,7 @@ export function renderPersonCard( apparel: 'peopleTabApparel', carry: 'peopleTabCarry', now: 'peopleTabNow', + connections: 'peopleTabConnections', portrait: 'peopleTabPortrait', }; @@ -142,6 +145,7 @@ export function renderPersonCard( fillApparel(panels.apparel, card); fillCarry(panels.carry, card); fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options); + fillConnections(panels.connections, card, onRelative); fillPortrait(panels.portrait, card, options); const showTab = (next: PersonCardTab): void => { @@ -160,7 +164,7 @@ export function renderPersonCard( showTab(tab); - parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.portrait); + parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait); } function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void { @@ -253,7 +257,47 @@ function fillCarry(parent: HTMLElement, card: PersonCard): void { ); } - parent.append(grid); + parent.append(grid ); +} + +function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void { + const connections = card.connections; + if (connections === null) { + return; + } + + const family = section(t('peopleFamily')); + appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative); + appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative); + appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative); + appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative); + if (family.childElementCount > 1) { + parent.append(family); + } + + appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative); + appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative); + + const search = el('input', { class: 'input people__input', type: 'search' }); + search.placeholder = t('peopleConnectionsSearch'); + const list = el('div', { class: 'people__connections-list' }); + const renderMatches = (): void => { + clear(list); + const query = search.value.trim().toLocaleLowerCase(); + const matches = connections.others.filter((row) => + query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query), + ); + if (matches.length === 0) { + list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') })); + return; + } + + appendOpinionLinks(list, '', matches, onRelative, false); + }; + + search.addEventListener('input', renderMatches); + parent.append(search, list); + renderMatches(); } function fillNow( @@ -682,3 +726,86 @@ function appendRelatives( ), ); } + +function appendRelativesWithOpinion( + parent: HTMLElement, + title: string, + relatives: readonly PersonRel[], + open: (id: string) => void, +): void { + if (relatives.length === 0) { + return; + } + + const list = el('span', { class: 'people__rel-list' }); + for (const relative of relatives) { + const caption = + relative.opinion !== null && + relative.opinion !== undefined && + relative.opinionLabel !== null && + relative.opinionLabel !== undefined + ? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion }) + : relative.fullName; + list.append( + el('button', { + class: 'people__link', + type: 'button', + text: caption, + title: relative.fullName, + onClick: () => open(relative.id), + }), + ); + } + + parent.append( + el( + 'div', + { class: 'people__rel' }, + el('span', { class: 'people__rel-title', text: title }), + list, + ), + ); +} + +function appendOpinionLinks( + parent: HTMLElement, + title: string, + rows: readonly PersonOpinionLink[], + open: (id: string) => void, + titled = true, +): void { + if (rows.length === 0) { + return; + } + + const block = el('div', { class: 'people__connections-block' }); + if (titled && title.length > 0) { + block.append(el('h4', { class: 'people__section-title', text: title })); + } + + const list = el('dl', { class: 'people__pairs' }); + for (const row of rows) { + list.append( + el( + 'div', + { class: 'people__pair' }, + el( + 'dt', + {}, + el('button', { + class: 'people__link', + type: 'button', + text: row.fullName, + onClick: () => open(row.id), + }), + ), + el('dd', { + text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }), + }), + ), + ); + } + + block.append(list); + parent.append(block); +} diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 460cef4..cc85c87 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -308,6 +308,38 @@ public sealed class BehaviorDef : Def /// Game minutes for ChangeClothes* actions. public float ChangeClothesMinutes { get; init; } = 5f; + /// Parent → child start. Child → parent uses . + public int OpinionParentToChildStart { get; init; } = 85; + + public int OpinionChildToParentStart { get; init; } = 75; + + public int OpinionSiblingStart { get; init; } = 45; + + public int OpinionPartnerStart { get; init; } = 65; + + /// Points each work morning moves an opinion toward family basis or zero. + public int OpinionDriftPerMorning { get; init; } = 3; + + /// How many non-family friends or enemies the card lists at the top. + public int OpinionTopCount { get; init; } = 5; + + /// + /// Caption bands for −100…100. Highest the value still meets wins. + /// Empty falls back to . + /// + public IReadOnlyList OpinionBands { get; init; } = DefaultOpinionBands; + + public static IReadOnlyList DefaultOpinionBands { get; } = + [ + new() { Min = 70, Id = "OpinionCloseFriend" }, + new() { Min = 40, Id = "OpinionFriend" }, + new() { Min = 20, Id = "OpinionPleasant" }, + new() { Min = 1, Id = "OpinionAcquaintance" }, + new() { Min = -1, Id = "OpinionStrained" }, + new() { Min = -40, Id = "OpinionDislike" }, + new() { Min = -100, Id = "OpinionEnemy" }, + ]; + public static IReadOnlyList DefaultConditionBands { get; } = [ new() { Min = 0.75f, Id = "ApparelConditionIntact" }, @@ -325,6 +357,14 @@ public sealed class ApparelConditionBand public required string Id { get; init; } } +/// One caption on an opinion value. is a locale key, not a Def. +public sealed class OpinionBand +{ + public int Min { get; init; } + + public required string Id { get; init; } +} + public enum BodyAttributeKind { Number, diff --git a/src/HSchool.People/OpinionGenerator.cs b/src/HSchool.People/OpinionGenerator.cs new file mode 100644 index 0000000..f356d21 --- /dev/null +++ b/src/HSchool.People/OpinionGenerator.cs @@ -0,0 +1,97 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// Seeds family opinions at generation or when an old save lacks them. +public static class OpinionGenerator +{ + public static Roster SeedFamily(DefCatalog catalog, Roster roster) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(roster); + + var rules = catalog.BehaviorRules; + if (rules is null) + { + 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 = SeedPersonFamilyOpinions(roster, rules, roster.People, person); + people[i] = updated; + changed |= !ReferenceEquals(updated, person); + } + + return changed ? roster with { People = people } : roster; + } + + public static bool NeedsFamilyOpinions(Roster roster) + { + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + foreach (var family in roster.Families) + { + foreach (var childId in family.ChildIds) + { + if (!people.TryGetValue(childId, out var child)) + { + continue; + } + + foreach (var parentId in family.ParentIds) + { + if (!child.Opinions.ContainsKey(parentId)) + { + return true; + } + } + } + } + + return false; + } + + public static Person EnsureMutableOpinions(Person person) + { + if (person.Opinions is Dictionary) + { + return person; + } + + return person with + { + Opinions = new Dictionary(person.Opinions, StringComparer.Ordinal), + }; + } + + private static Person SeedPersonFamilyOpinions( + Roster roster, + BehaviorDef rules, + IReadOnlyList rosterPeople, + Person person) + { + var mutable = EnsureMutableOpinions(person); + var changed = !ReferenceEquals(mutable, person); + var peopleById = rosterPeople.ToDictionary(row => row.Id, StringComparer.Ordinal); + foreach (var targetId in OpinionStore.FamilyMemberIds(roster, person)) + { + if (!peopleById.TryGetValue(targetId, out var target)) + { + continue; + } + + var basis = OpinionStore.FamilyBasis(roster, rules, mutable, target); + if (basis == 0 || mutable.Opinions.ContainsKey(targetId)) + { + continue; + } + + changed |= OpinionStore.Set(mutable, targetId, basis); + } + + return changed ? mutable : person; + } +} diff --git a/src/HSchool.People/OpinionLabels.cs b/src/HSchool.People/OpinionLabels.cs new file mode 100644 index 0000000..b59bae6 --- /dev/null +++ b/src/HSchool.People/OpinionLabels.cs @@ -0,0 +1,30 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// Threshold captions live in the catalog. The client draws what it is told. +public static class OpinionLabels +{ + public const int MinValue = -100; + public const int MaxValue = 100; + + public static string BandId(BehaviorDef? rules, int value) + { + var bands = rules?.OpinionBands is { Count: > 0 } listed + ? listed + : BehaviorDef.DefaultOpinionBands; + OpinionBand? best = null; + foreach (var band in bands) + { + if (value >= band.Min && (best is null || band.Min > best.Min)) + { + best = band; + } + } + + return best?.Id ?? "OpinionAcquaintance"; + } + + public static string Label(DefCatalog catalog, string locale, int value) => + catalog.Text(locale, BandId(catalog.BehaviorRules, value)); +} diff --git a/src/HSchool.People/OpinionStore.cs b/src/HSchool.People/OpinionStore.cs new file mode 100644 index 0000000..eb0dcc6 --- /dev/null +++ b/src/HSchool.People/OpinionStore.cs @@ -0,0 +1,147 @@ +using HSchool.Content; + +namespace HSchool.People; + +/// Sparse A→B opinions on a person. Zero is not stored. +public static class OpinionStore +{ + public static int? Get(Person person, string targetId) => + person.Opinions.TryGetValue(targetId, out var value) ? value : null; + + public static bool Set(Person person, string targetId, int value) + { + value = Math.Clamp(value, OpinionLabels.MinValue, OpinionLabels.MaxValue); + if (value == 0) + { + return Remove(person, targetId); + } + + if (person.Opinions is not IDictionary dict) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + if (dict.IsReadOnly) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + if (dict.TryGetValue(targetId, out var existing) && existing == value) + { + return false; + } + + dict[targetId] = value; + return true; + } + + public static bool Remove(Person person, string targetId) + { + if (person.Opinions is not IDictionary dict) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + if (dict.IsReadOnly) + { + throw new InvalidOperationException($"Cannot mutate opinions for {person.Id}."); + } + + return dict.Remove(targetId); + } + + public static int FamilyBasis(Roster roster, BehaviorDef rules, Person from, Person to) + { + var family = roster.Families.FirstOrDefault(candidate => + candidate.Id.Equals(from.FamilyId, StringComparison.Ordinal) + && candidate.Id.Equals(to.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return 0; + } + + var fromParent = InFamily(family.ParentIds, from.Id); + var toParent = InFamily(family.ParentIds, to.Id); + var fromChild = InFamily(family.ChildIds, from.Id); + var toChild = InFamily(family.ChildIds, to.Id); + + if (fromParent && toChild) + { + return rules.OpinionParentToChildStart; + } + + if (fromChild && toParent) + { + return rules.OpinionChildToParentStart; + } + + if (fromChild && toChild && !from.Id.Equals(to.Id, StringComparison.Ordinal)) + { + return rules.OpinionSiblingStart; + } + + if (fromParent && toParent && !from.Id.Equals(to.Id, StringComparison.Ordinal)) + { + return rules.OpinionPartnerStart; + } + + return 0; + } + + public static int MoveToward(int current, int target, int step) + { + if (current == target) + { + return current; + } + + if (current < target) + { + return Math.Min(current + step, target); + } + + return Math.Max(current - step, target); + } + + public static HashSet FamilyMemberIds(Roster roster, Person person) + { + var family = roster.Families.FirstOrDefault(candidate => + candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return []; + } + + var ids = new HashSet(StringComparer.Ordinal); + foreach (var id in family.ParentIds) + { + if (!id.Equals(person.Id, StringComparison.Ordinal)) + { + ids.Add(id); + } + } + + foreach (var id in family.ChildIds) + { + if (!id.Equals(person.Id, StringComparison.Ordinal)) + { + ids.Add(id); + } + } + + return ids; + } + + private static bool InFamily(IReadOnlyList ids, string id) + { + foreach (var candidate in ids) + { + if (candidate.Equals(id, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 8a917b3..9b58c68 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -52,6 +52,9 @@ public sealed record Person /// Changing-room node when this pupil won a locker slot. Null for staff and the rest. public string? LockerRoomId { get; init; } + /// What this person thinks of others, A→B. Sparse; zero is not stored. + public IReadOnlyDictionary Opinions { get; init; } = new Dictionary(StringComparer.Ordinal); + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index 4699b81..f9aaebb 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -44,7 +44,8 @@ public static class RosterGenerator } var classes = FillClasses(demand.Classes, people); - return LockerAssigner.Apply(catalog, map, new Roster(people, families, classes)); + var roster = LockerAssigner.Apply(catalog, map, new Roster(people, families, classes)); + return OpinionGenerator.SeedFamily(catalog, roster); } /// diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index afaa4e8..d951e12 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -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, @@ -111,7 +112,25 @@ internal sealed record PersonFamilyResponse( IReadOnlyList Siblings, IReadOnlyList 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 Friends, + IReadOnlyList Enemies, + IReadOnlyList Others); internal sealed record DirectoryResponse(IReadOnlyList People); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index fb66f50..671959d 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -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? LiveNeeds(World world, string personId) @@ -373,8 +374,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) : [], @@ -382,6 +383,19 @@ internal static class PersonCardReader inParents ? Relatives(family.ParentIds, people, except: person.Id) : []); } + private static bool InFamily(IReadOnlyList ids, string id) + { + foreach (var candidate in ids) + { + if (candidate.Equals(id, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + private static IReadOnlyList Relatives( IReadOnlyList ids, IReadOnlyDictionary people, @@ -400,4 +414,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(); + 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 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 RelativesWithOpinions( + IReadOnlyList ids, + IReadOnlyDictionary people, + Person person, + string except, + DefCatalog? catalog, + string locale) + { + var rows = new List(); + 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)); } diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index e066858..7a3b0ee 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -984,6 +984,12 @@ internal sealed class SchoolWorker generated = true; } + if (OpinionGenerator.NeedsFamilyOpinions(roster)) + { + roster = OpinionGenerator.SeedFamily(catalog, roster); + generated = true; + } + roster = LockerAssigner.Apply(catalog, map, roster); if (!RosterFit.Matches(roster, demand)) diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index bd665eb..079b9f3 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -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" }, + ], } diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index f88bf3f..2fc6e69 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -173,6 +173,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}", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index f7bdfaf..1514078 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -173,6 +173,13 @@ "ApparelConditionWorn": "поношенная", "ApparelConditionTorn": "порванная", "ApparelConditionRags": "висит лохмотьями", + "OpinionCloseFriend": "близкие друзья", + "OpinionFriend": "друзья", + "OpinionPleasant": "приятели", + "OpinionAcquaintance": "знакомые", + "OpinionStrained": "натянуто", + "OpinionDislike": "неприязнь", + "OpinionEnemy": "враги", "ActionStarted": "начал: {0}", "ActionEnded": "закончил: {0}", "ApparelReplaced": "получил новую {0}", diff --git a/src/HSchool.Simulation/ApparelWear.cs b/src/HSchool.Simulation/ApparelWear.cs index cf21fd5..b14d53b 100644 --- a/src/HSchool.Simulation/ApparelWear.cs +++ b/src/HSchool.Simulation/ApparelWear.cs @@ -53,9 +53,10 @@ internal static class ApparelWear } var morningChanged = MorningDress.Apply(school); + var opinionsChanged = MorningOpinions.Apply(school); var ragsReplaced = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays) && ReplaceRags(school); - return bandCrossed | morningChanged | ragsReplaced; + return bandCrossed | morningChanged | opinionsChanged | ragsReplaced; } internal static bool CrossedDayStart(DateTime before, DateTime after) diff --git a/src/HSchool.Simulation/MorningOpinions.cs b/src/HSchool.Simulation/MorningOpinions.cs new file mode 100644 index 0000000..d216043 --- /dev/null +++ b/src/HSchool.Simulation/MorningOpinions.cs @@ -0,0 +1,66 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// Drifts non-zero opinions toward family basis or zero at six on work mornings. +internal static class MorningOpinions +{ + public static bool Apply(School school) + { + if (school.Catalog?.BehaviorRules is not { } rules || school.Roster is null) + { + return false; + } + + if (!SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays)) + { + return false; + } + + var peopleById = school.Roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var changed = false; + foreach (var person in school.Roster.People) + { + changed |= DriftPerson(school.Roster, rules, peopleById, person, rules.OpinionDriftPerMorning); + } + + return changed; + } + + private static bool DriftPerson( + Roster roster, + BehaviorDef rules, + IReadOnlyDictionary peopleById, + Person person, + int drift) + { + if (person.Opinions.Count == 0) + { + return false; + } + + var changed = false; + foreach (var (targetId, current) in person.Opinions.ToArray()) + { + if (!peopleById.TryGetValue(targetId, out var target)) + { + changed |= OpinionStore.Remove(person, targetId); + continue; + } + + var basis = OpinionStore.FamilyBasis(roster, rules, person, target); + var next = OpinionStore.MoveToward(current, basis, drift); + if (next == 0) + { + changed |= OpinionStore.Remove(person, targetId); + } + else + { + changed |= OpinionStore.Set(person, targetId, next); + } + } + + return changed; + } +} diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs index 511f474..b43cdcb 100644 --- a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -177,6 +177,36 @@ public class PeopleApiTests(AppHostFixture fixture) Assert.Contains(motherCard.Family.Children, child => child.Id == card.Id); } + [Fact] + public async Task Card_ConnectionsCarryOnlyThisPersonsOpinions() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Связи карточка", Start, seed: 1); + + var pupils = await GetPeopleAsync(client, school.Id, "role=student&pageSize=20"); + var pupil = pupils.People.First(person => person.Roles.Contains("student")); + var card = await GetCardAsync(client, school.Id, pupil.Id); + + Assert.NotNull(card.Connections); + Assert.NotEmpty(card.Connections!.Family.Parents); + Assert.All(card.Connections.Family.Parents, parent => + { + Assert.NotNull(parent.Opinion); + Assert.True(parent.Opinion > 0); + Assert.False(string.IsNullOrWhiteSpace(parent.OpinionLabel)); + }); + + var linkedIds = card.Connections.Others.Select(row => row.Id).ToHashSet(StringComparer.Ordinal); + foreach (var other in card.Connections.Friends.Concat(card.Connections.Enemies)) + { + Assert.DoesNotContain(other.Id, linkedIds); + } + + using var bulk = await client.GetAsync($"/api/schools/{school.Id}/people/opinions", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, bulk.StatusCode); + } + /// /// Phases 7 and 9 together: the roster survives a restart, and what comes back is the /// composition *after* the first-September intake. Generating from the seed again would @@ -320,7 +350,8 @@ public class PeopleApiTests(AppHostFixture fixture) IReadOnlyList Skills, IReadOnlyList Traits, IReadOnlyList Needs, - PersonFamilyResponse Family); + PersonFamilyResponse Family, + PersonConnectionsResponse? Connections); private sealed record LabeledStatResponse(string Id, string Label, string Value); @@ -332,7 +363,25 @@ public class PeopleApiTests(AppHostFixture fixture) IReadOnlyList Siblings, IReadOnlyList Partners); - private sealed record PersonRelResponse(string Id, string FullName, bool Female); + private sealed record PersonRelResponse( + string Id, + string FullName, + bool Female, + int? Opinion = null, + string? OpinionLabel = null); + + private sealed record PersonOpinionLinkResponse( + string Id, + string FullName, + bool Female, + int Opinion, + string OpinionLabel); + + private sealed record PersonConnectionsResponse( + PersonFamilyResponse Family, + IReadOnlyList Friends, + IReadOnlyList Enemies, + IReadOnlyList Others); private sealed record DirectoryResponse(IReadOnlyList People); diff --git a/tests/HSchool.People.Tests/OpinionGeneratorTests.cs b/tests/HSchool.People.Tests/OpinionGeneratorTests.cs new file mode 100644 index 0000000..0250069 --- /dev/null +++ b/tests/HSchool.People.Tests/OpinionGeneratorTests.cs @@ -0,0 +1,55 @@ +namespace HSchool.People.Tests; + +public class OpinionGeneratorTests +{ + [Fact] + public void ParentAndChild_HavePositiveOpinionsBothWays_AfterGeneration() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + + var child = roster.People.First(person => person.IsStudent && !person.IsParent); + var family = roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)); + Assert.NotEmpty(family.ParentIds); + + foreach (var parentId in family.ParentIds) + { + Assert.True(child.Opinions.TryGetValue(parentId, out var toParent)); + Assert.True(toParent > 0); + + var parent = people[parentId]; + Assert.True(parent.Opinions.TryGetValue(child.Id, out var toChild)); + Assert.True(toChild > 0); + } + } + + [Fact] + public void Classmates_HaveNoOpinionEntry_AfterGeneration() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var pupils = roster.People.Where(person => person.IsStudent).ToArray(); + Assert.True(pupils.Length >= 2); + + var first = pupils[0]; + var classmate = pupils.First(person => + person.Id != first.Id + && !OpinionStore.FamilyMemberIds(roster, first).Contains(person.Id)); + + Assert.False(first.Opinions.ContainsKey(classmate.Id)); + Assert.False(classmate.Opinions.ContainsKey(first.Id)); + } + + [Fact] + public void ParentToChild_AndChildToParent_CanDiffer() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var child = roster.People.First(person => person.IsStudent && !person.IsParent); + var parentId = roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)).ParentIds[0]; + var parent = people[parentId]; + + Assert.True(parent.Opinions.TryGetValue(child.Id, out var parentView)); + Assert.True(child.Opinions.TryGetValue(parentId, out var childView)); + Assert.NotEqual(parentView, childView); + } +} diff --git a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs new file mode 100644 index 0000000..e306204 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs @@ -0,0 +1,117 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class MorningOpinionsTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime TuesdayEvening = new(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc); + private static readonly DateTime WednesdayMorning = new(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void SeveralWorkMornings_DriftClassmateTowardZero_FamilyTowardStart() + { + using var school = OpenStaffed(); + var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + var classmate = school.Roster.People.First(person => + person.IsStudent + && !person.Id.Equals(child.Id, StringComparison.Ordinal) + && !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id)); + var family = school.Roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)); + var parentId = family.ParentIds[0]; + var rules = school.Catalog!.BehaviorRules!; + + OpinionStore.Set(child, classmate.Id, 30); + OpinionStore.Set(child, parentId, rules.OpinionChildToParentStart - 15); + + AdvanceTo(school, TuesdayEvening); + for (var day = 0; day < 3; day++) + { + while (school.Clock.Time < WednesdayMorning.AddDays(day)) + { + school.Tick(0.2d, 5d); + } + } + + Assert.True(child.Opinions.TryGetValue(classmate.Id, out var classmateView)); + Assert.True(classmateView < 30); + Assert.True(classmateView <= 30 - rules.OpinionDriftPerMorning); + + Assert.True(child.Opinions.TryGetValue(parentId, out var parentView)); + Assert.True(parentView > rules.OpinionChildToParentStart - 15); + Assert.True(parentView <= rules.OpinionChildToParentStart); + } + + [Fact] + public void SkipEmpty_AppliesOpinionDrift() + { + using var school = OpenEmpty(TuesdayEvening); + var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + var classmate = school.Roster.People.First(person => + person.IsStudent + && !person.Id.Equals(child.Id, StringComparison.Ordinal) + && !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id)); + + OpinionStore.Set(child, classmate.Id, 12); + var before = child.Opinions[classmate.Id]; + + Assert.True(school.TrySkipEmpty().Succeeded); + + Assert.True(child.Opinions.TryGetValue(classmate.Id, out var after)); + Assert.True(after < before); + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static School OpenStaffed() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); + var school = School.Create(1, "Мнения", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + return school; + } + + private static School OpenEmpty(DateTime start) + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start); + var school = School.Create(1, "Мнения ночь", start, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + return school; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}