From fdcaed0f74889ddf2db24a2692d9d45be0caa242 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 09:37:12 +0300 Subject: [PATCH] Remember recent quarrels, fights and reprimands on the person. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short offense list for the pupil card and save, capped by BehaviorDef — not a discipline score. --- docs/phases/12-office/69-offense-memory.md | 20 +- docs/protocol.md | 15 ++ src/HSchool.Client/src/i18n/strings.ts | 6 + src/HSchool.Client/src/net/api.ts | 10 + src/HSchool.Client/src/style.css | 24 +++ src/HSchool.Client/src/ui/personCard.test.ts | 34 +++ src/HSchool.Client/src/ui/personCard.ts | 4 +- src/HSchool.Client/src/ui/personCardNow.ts | 42 +++- src/HSchool.Content/PeopleDefValidator.cs | 13 ++ src/HSchool.Content/PeopleDefs.cs | 18 ++ src/HSchool.People/OffenseMemory.cs | 103 +++++++++ src/HSchool.People/Roster.cs | 6 + src/HSchool.Server/Api/PeopleModels.cs | 10 +- src/HSchool.Server/Game/PersonCardReader.cs | 44 +++- .../mods/core/defs/behavior/rules.jsonc | 3 + .../mods/core/localizations/en.jsonc | 3 + .../mods/core/localizations/ru.jsonc | 3 + src/HSchool.Simulation/TalkCircleSystem.cs | 26 +++ .../OffenseMemoryTests.cs | 118 +++++++++++ .../HSchool.Server.Tests.csproj | 7 + .../PersonCardReaderOffensesTests.cs | 69 ++++++ .../OffenseMemorySimulationTests.cs | 198 ++++++++++++++++++ 22 files changed, 760 insertions(+), 16 deletions(-) create mode 100644 src/HSchool.People/OffenseMemory.cs create mode 100644 tests/HSchool.People.Tests/OffenseMemoryTests.cs create mode 100644 tests/HSchool.Server.Tests/PersonCardReaderOffensesTests.cs create mode 100644 tests/HSchool.Simulation.Tests/OffenseMemorySimulationTests.cs diff --git a/docs/phases/12-office/69-offense-memory.md b/docs/phases/12-office/69-offense-memory.md index aeaa87d..f5cd05a 100644 --- a/docs/phases/12-office/69-offense-memory.md +++ b/docs/phases/12-office/69-offense-memory.md @@ -10,20 +10,20 @@ ## Задачи -- [ ] На человеке разреженный список проступков: вид, игровое время (или день+слот), опционально +- [x] На человеке разреженный список проступков: вид, игровое время (или день+слот), опционально второй участник -- [ ] Потолок длины и какие факты пишут — поля `BehaviorDef` / данные `core`, не константы в UI -- [ ] Писать при ссоре, драке, выговоре (обрыв драки сотрудником); старые вытесняются -- [ ] Сейв вместе с человеком; ноль записей не хранить раздуто -- [ ] Карточка ученика показывает несколько последних (HTTP через мейлбокс) -- [ ] Строки видов через локаль мода / `t(...)` по договорённости фазы; обе локали клиента для UI +- [x] Потолок длины и какие факты пишут — поля `BehaviorDef` / данные `core`, не константы в UI +- [x] Писать при ссоре, драке, выговоре (обрыв драки сотрудником); старые вытесняются +- [x] Сейв вместе с человеком; ноль записей не хранить раздуто +- [x] Карточка ученика показывает несколько последних (HTTP через мейлбокс) +- [x] Строки видов через локаль мода / `t(...)` по договорённости фазы; обе локали клиента для UI ## Тесты, без которых фаза не закрыта -- [ ] Драка с выговором добавляет запись жертве и/или задире по правилам фазы -- [ ] Сверх потолка старая запись исчезает -- [ ] Список переживает сейв -- [ ] Карточка отдает тот же порядок, что в мире (не клиентская фантазия) +- [x] Драка с выговором добавляет запись жертве и/или задире по правилам фазы +- [x] Сверх потолка старая запись исчезает +- [x] Список переживает сейв +- [x] Карточка отдает тот же порядок, что в мире (не клиентская фантазия) ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 7b4eec9..8d0eed3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -365,6 +365,12 @@ When a school was created with a pack that ships orientations, the card also car pack those fields are null or empty — the client hides the sympathy column. The client does not compute thresholds. There is no school-wide opinions endpoint. +`offenses` is the short recent-misconduct list on the person (quarrel / fight / reprimand). Same +order as in the world and in `people.json`. Each row has `kind`, `kindLabel` (mod locale), +`time` (UTC game clock), and optional `otherPersonId` / `otherFullName`. Empty is `[]`. Ceiling +and which kinds write live on `BehaviorDef` (`offenseMemoryMax`, `offenseMemoryKinds`), not in +the client. HTTP JSON is additive — no protocol version bump. + ```json { "id": "f0.c0", @@ -420,6 +426,15 @@ compute thresholds. There is no school-wide opinions endpoint. "hasCustom": false, "hasFullBody": false, "customPortraitPrompt": null, + "offenses": [ + { + "kind": "fight", + "kindLabel": "драка", + "time": "2012-04-03T11:20:00Z", + "otherPersonId": "f0.c1", + "otherFullName": "Иванов Кирилл Петрович" + } + ], "connections": { "family": { "parents": [ diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 39a6ae0..a0b3b16 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -232,6 +232,9 @@ const ru = { peopleLogTime: 'Время', peopleLogEvent: 'Событие', peopleLogEmpty: 'Сегодня записей нет.', + peopleOffenses: 'Проступки', + peopleOffensesEmpty: 'Недавних проступков нет.', + peopleOffenseWith: '{kind} · {other}', peoplePortraitAvatar: 'Аватар', peoplePortraitFull: 'В полный рост', @@ -626,6 +629,9 @@ const en: Messages = { peopleLogTime: 'Time', peopleLogEvent: 'Event', peopleLogEmpty: 'Nothing logged today.', + peopleOffenses: 'Misconduct', + peopleOffensesEmpty: 'No recent misconduct.', + peopleOffenseWith: '{kind} · {other}', peoplePortraitAvatar: 'Avatar', peoplePortraitFull: 'Full body', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 166ccb6..f65bacc 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -355,6 +355,16 @@ export interface PersonCard { readonly customPortraitPrompt: string | null; readonly connections: PersonConnections | null; readonly orientation?: DefLabel | null; + /** Recent misconduct from the person save — same order as the world list. */ + readonly offenses?: readonly OffenseEntry[] | null; +} + +export interface OffenseEntry { + readonly kind: string; + readonly kindLabel: string; + readonly time: string; + readonly otherPersonId: string | null; + readonly otherFullName: string | null; } export interface WornItem { diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 84b0f3d..739cfb4 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -812,6 +812,30 @@ body { color: var(--text-muted); } +.people__offenses { + margin: 12px 0 0; +} + +.people__offense-list { + list-style: none; + margin: 6px 0 0; + padding: 0; + display: grid; + gap: 4px; +} + +.people__offense { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + font-size: 0.9rem; +} + +.people__offense-when { + color: var(--text-muted); + white-space: nowrap; +} + .people__tabs { display: flex; flex-wrap: wrap; diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index b942c4d..be1182d 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -380,4 +380,38 @@ describe('renderPersonCard', () => { t('locationTalking', { partners: 'Петрова Маша', topic: t('talkTopicTopicSport') }), ); }); + + it('shows misconduct from the card on the Now tab without inventing order', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard( + root, + card({ + offenses: [ + { + kind: 'quarrel', + kindLabel: 'ссора', + time: '2012-04-03T10:00:00Z', + otherPersonId: 'f0.c1', + otherFullName: 'Иванов Кирилл', + }, + { + kind: 'fight', + kindLabel: 'драка', + time: '2012-04-03T11:00:00Z', + otherPersonId: 'f0.c1', + otherFullName: 'Иванов Кирилл', + }, + ], + }), + () => {}, + options({ tab: 'now', log: logPage() }), + ); + + const items = [...root.querySelectorAll('.people__offense-event')].map((node) => node.textContent ?? ''); + expect(root.textContent).toContain(t('peopleOffenses')); + expect(items).toHaveLength(2); + expect(items[0]).toContain('ссора'); + expect(items[1]).toContain('драка'); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index cd76be0..8d15f99 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -150,7 +150,9 @@ export function renderPersonCard( const log = panels.now.querySelector('.people__log, .people__log-tools'); if (next !== 'now' && log !== null) { - for (const node of [...panels.now.querySelectorAll('.people__log, .people__log-tools, .people__pager, .panel__empty')]) { + for (const node of [ + ...panels.now.querySelectorAll('.people__log, .people__log-tools, .people__pager, .people__log-empty'), + ]) { node.remove(); } } diff --git a/src/HSchool.Client/src/ui/personCardNow.ts b/src/HSchool.Client/src/ui/personCardNow.ts index d2d405a..599135e 100644 --- a/src/HSchool.Client/src/ui/personCardNow.ts +++ b/src/HSchool.Client/src/ui/personCardNow.ts @@ -1,5 +1,5 @@ -import type { PersonCard, PersonLogPage } from '../net/api.ts'; -import { formatGameTimeOfDay } from '../format/gameTime.ts'; +import type { OffenseEntry, PersonCard, PersonLogPage } from '../net/api.ts'; +import { formatGameDate, formatGameTimeOfDay } from '../format/gameTime.ts'; import { talkCircleText } from '../format/talkCircle.ts'; import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; @@ -39,6 +39,8 @@ export function fillNow( parent.append(el('p', { class: 'people__now-activity', text: activity })); } + parent.append(offensesBlock(card.offenses ?? [])); + if (log === null || log === undefined) { return; } @@ -75,7 +77,7 @@ export function fillNow( if (log.entries.length === 0) { parent.append( el('div', { class: 'people__log-tools' }, search, dir), - el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }), + el('p', { class: 'panel__empty people__log-empty', text: t('peopleLogEmpty') }), ); return; } @@ -118,3 +120,37 @@ export function fillNow( ), ); } + +function offensesBlock(offenses: readonly OffenseEntry[]): HTMLElement { + const wrap = el('div', { class: 'people__offenses' }); + wrap.append(el('h4', { class: 'people__section-title', text: t('peopleOffenses') })); + if (offenses.length === 0) { + wrap.append(el('p', { class: 'panel__empty', text: t('peopleOffensesEmpty') })); + return wrap; + } + + const list = el('ul', { class: 'people__offense-list' }); + for (const row of offenses) { + const when = formatOffenseWhen(row.time); + const event = + row.otherFullName !== null && row.otherFullName.length > 0 + ? t('peopleOffenseWith', { kind: row.kindLabel, other: row.otherFullName }) + : row.kindLabel; + list.append( + el( + 'li', + { class: 'people__offense' }, + el('span', { class: 'people__offense-when', text: when }), + el('span', { class: 'people__offense-event', text: event }), + ), + ); + } + + wrap.append(list); + return wrap; +} + +function formatOffenseWhen(iso: string): string { + const date = new Date(iso); + return `${formatGameDate(date)} ${formatGameTimeOfDay(date)}`; +} diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 7023f27..e2e1bbe 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -652,6 +652,19 @@ internal static class PeopleDefValidator { throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apologyRestoreFraction must be 0–1."); } + + if (behavior.OffenseMemoryMax < 0) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryMax cannot be negative."); + } + + foreach (var kind in behavior.OffenseMemoryKinds) + { + if (string.IsNullOrWhiteSpace(kind)) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' offenseMemoryKinds cannot contain empty ids."); + } + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index a2dd924..3f84a20 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -566,6 +566,24 @@ public sealed class BehaviorDef : Def /// Opinion of the victim at or above this — a third person may join the quarrel. public int DefendOpinionMin { get; init; } = 40; + /// + /// How many recent misconduct rows a person keeps. Oldest drop first. Zero disables the list. + /// + public int OffenseMemoryMax { get; init; } = 5; + + /// + /// Kind ids that may be appended (quarrel, fight, reprimand, …). + /// Empty falls back to . + /// + public IReadOnlyList OffenseMemoryKinds { get; init; } = DefaultOffenseMemoryKinds; + + public static IReadOnlyList DefaultOffenseMemoryKinds { get; } = + [ + "quarrel", + "fight", + "reprimand", + ]; + public static IReadOnlyList DefaultOpinionBands { get; } = [ new() { Min = 70, Id = "OpinionCloseFriend" }, diff --git a/src/HSchool.People/OffenseMemory.cs b/src/HSchool.People/OffenseMemory.cs new file mode 100644 index 0000000..9c03a46 --- /dev/null +++ b/src/HSchool.People/OffenseMemory.cs @@ -0,0 +1,103 @@ +using System.Text.Json.Serialization; +using HSchool.Content; + +namespace HSchool.People; + +/// Stable kind ids written into and listed on . +public static class OffenseKinds +{ + public const string Quarrel = "quarrel"; + + public const string Fight = "fight"; + + public const string Reprimand = "reprimand"; + + public static string LocaleKey(string kind) => kind switch + { + Quarrel => "OffenseQuarrel", + Fight => "OffenseFight", + Reprimand => "OffenseReprimand", + _ => "Offense" + char.ToUpperInvariant(kind[0]) + kind[1..], + }; +} + +/// One remembered misconduct. Sparse list on the person — not a discipline score. +public sealed class OffenseRecord +{ + public required string Kind { get; init; } + + public required DateTime Time { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OtherPersonId { get; init; } +} + +/// Appends recent offenses and drops the oldest when over the BehaviorDef ceiling. +public static class OffenseMemory +{ + public static bool IsEnabled(string kind, BehaviorDef rules) + { + foreach (var allowed in rules.OffenseMemoryKinds) + { + if (allowed.Equals(kind, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + public static bool Record(Person person, string kind, DateTime time, string? otherPersonId, BehaviorDef rules) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(rules); + ArgumentException.ThrowIfNullOrWhiteSpace(kind); + + if (rules.OffenseMemoryMax <= 0 || !IsEnabled(kind, rules)) + { + return false; + } + + var list = person.Offenses; + if (list is null) + { + list = []; + person.Offenses = list; + } + + list.Add(new OffenseRecord + { + Kind = kind, + Time = DateTime.SpecifyKind(time, DateTimeKind.Utc), + OtherPersonId = string.IsNullOrWhiteSpace(otherPersonId) ? null : otherPersonId, + }); + + while (list.Count > rules.OffenseMemoryMax) + { + list.RemoveAt(0); + } + + return true; + } + + public static string? OtherParticipant(string personId, string? victimId, IReadOnlyList members) + { + if (victimId is not null + && !victimId.Equals(personId, StringComparison.Ordinal) + && members.Contains(victimId, StringComparer.Ordinal)) + { + return victimId; + } + + foreach (var member in members) + { + if (!member.Equals(personId, StringComparison.Ordinal)) + { + return member; + } + } + + return null; + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 00c0ee2..bb01abc 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -69,6 +69,12 @@ public sealed record Person /// public string? BullyVictimId { get; set; } + /// + /// Recent misconduct (quarrel / fight / reprimand). Null when empty so people.json stays compact. + /// Ceiling and which kinds write live on BehaviorDef. + /// + public List? Offenses { get; set; } + public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf); } diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index eedfc89..78bf4fb 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -85,7 +85,15 @@ internal sealed record PersonCardResponse( PersonConnectionsResponse? Connections = null, DefLabelResponse? Orientation = null, IReadOnlyList? TalkCircleMemberIds = null, - string? TalkTopicId = null); + string? TalkTopicId = null, + IReadOnlyList? Offenses = null); + +internal sealed record OffenseEntryResponse( + string Kind, + string KindLabel, + DateTime Time, + string? OtherPersonId, + string? OtherFullName); internal sealed record WornItemResponse( diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 27d639f..0f50b03 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -98,7 +98,49 @@ internal static partial class PersonCardReader Connections: Connections(roster, person, catalog, locale), Orientation: OrientationOf(person, catalog, locale), TalkCircleMemberIds: circle?.MemberIds ?? [], - TalkTopicId: circle?.TopicId); + TalkTopicId: circle?.TopicId, + Offenses: Offenses(roster, person, catalog, locale)); + } + + private static IReadOnlyList Offenses( + Roster roster, + Person person, + DefCatalog? catalog, + string locale) + { + var rows = person.Offenses; + if (rows is null || rows.Count == 0) + { + return []; + } + + var byId = roster.People.ToDictionary(candidate => candidate.Id, StringComparer.Ordinal); + var result = new OffenseEntryResponse[rows.Count]; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + string? otherName = null; + if (row.OtherPersonId is { } otherId && byId.TryGetValue(otherId, out var other)) + { + otherName = other.Name.Full; + } + + var label = KindLabel(catalog, locale, row.Kind); + result[i] = new OffenseEntryResponse(row.Kind, label, row.Time, row.OtherPersonId, otherName); + } + + return result; + } + + private static string KindLabel(DefCatalog? catalog, string locale, string kind) + { + var key = OffenseKinds.LocaleKey(kind); + if (catalog is not null && catalog.HasText(locale, key)) + { + return catalog.Text(locale, key); + } + + return kind; } private static IReadOnlyDictionary? LiveNeeds(World world, string personId) diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 11d1552..8480075 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -96,4 +96,7 @@ "fightOpinionShift": -16, "apologyRestoreFraction": 0.5, "defendOpinionMin": 40, + // Short misconduct list on the person (slice 12 phase 69). Not a 0…100 score. + "offenseMemoryMax": 5, + "offenseMemoryKinds": ["quarrel", "fight", "reprimand"], } diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 64cbb61..9054b26 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -208,6 +208,9 @@ "Fought": "fought", "Apologized": "apologized", "Reprimanded": "reprimanded", + "OffenseQuarrel": "quarrel", + "OffenseFight": "fight", + "OffenseReprimand": "reprimand", "TopicStudy": "schoolwork", "TopicGames": "games", "TopicFood": "food", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 5fceaba..7f76765 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -208,6 +208,9 @@ "Fought": "дрались", "Apologized": "извинился", "Reprimanded": "выговор", + "OffenseQuarrel": "ссора", + "OffenseFight": "драка", + "OffenseReprimand": "выговор", "TopicStudy": "учёбе", "TopicGames": "играх", "TopicFood": "еде", diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 8ac3b6e..78cc82c 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -997,6 +997,7 @@ internal static class TalkCircleSystem ? Conflict.FightOpinionDelta(rules) : Conflict.QuarrelOpinionDelta(rules); ApplyClash(school, circle, people, action, delta); + RecordConflictOffenses(school, circle, people, rules, reprimanded); if (reprimanded) { foreach (var person in people) @@ -1020,6 +1021,31 @@ internal static class TalkCircleSystem school.RosterTalkDirty = true; } + private static void RecordConflictOffenses( + School school, + ActiveTalkCircle circle, + IReadOnlyList people, + BehaviorDef? rules, + bool reprimanded) + { + if (rules is null) + { + return; + } + + var kind = TalkActions.IsFight(circle.ActionId) ? OffenseKinds.Fight : OffenseKinds.Quarrel; + var time = school.Clock.Time; + foreach (var person in people) + { + var other = OffenseMemory.OtherParticipant(person.Id, circle.VictimId, circle.Members); + OffenseMemory.Record(person, kind, time, other, rules); + if (reprimanded) + { + OffenseMemory.Record(person, OffenseKinds.Reprimand, time, other, rules); + } + } + } + private static void ApplyClash( School school, ActiveTalkCircle circle, diff --git a/tests/HSchool.People.Tests/OffenseMemoryTests.cs b/tests/HSchool.People.Tests/OffenseMemoryTests.cs new file mode 100644 index 0000000..288fca6 --- /dev/null +++ b/tests/HSchool.People.Tests/OffenseMemoryTests.cs @@ -0,0 +1,118 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class OffenseMemoryTests +{ + [Fact] + public void OverCeiling_DropsTheOldest() + { + var person = Blank("a"); + var rules = Rules(max: 2); + var t0 = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc); + Assert.True(OffenseMemory.Record(person, OffenseKinds.Quarrel, t0, "b", rules)); + Assert.True(OffenseMemory.Record(person, OffenseKinds.Fight, t0.AddMinutes(1), "b", rules)); + Assert.True(OffenseMemory.Record(person, OffenseKinds.Reprimand, t0.AddMinutes(2), "b", rules)); + + Assert.Equal(2, person.Offenses!.Count); + Assert.Equal(OffenseKinds.Fight, person.Offenses[0].Kind); + Assert.Equal(OffenseKinds.Reprimand, person.Offenses[1].Kind); + } + + [Fact] + public void DisabledKind_DoesNotWrite() + { + var person = Blank("a"); + var rules = new BehaviorDef + { + DefName = "Behavior", + OffenseMemoryMax = 5, + OffenseMemoryKinds = [OffenseKinds.Fight], + }; + Assert.False(OffenseMemory.Record( + person, + OffenseKinds.Quarrel, + new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc), + "b", + rules)); + Assert.Null(person.Offenses); + } + + [Fact] + public void RosterJson_RoundTripsOffenses_AndOmitsEmpty() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var pupil = roster.People.First(person => person.IsStudent && !person.IsParent); + var other = roster.People.First(person => + person.IsStudent && !person.Id.Equals(pupil.Id, StringComparison.Ordinal)); + var time = new DateTime(2012, 4, 3, 11, 20, 0, DateTimeKind.Utc); + OffenseMemory.Record(pupil, OffenseKinds.Fight, time, other.Id, Rules(max: 5)); + + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + Assert.Contains("\"offenses\"", json, StringComparison.Ordinal); + Assert.Contains("\"kind\": \"fight\"", json, StringComparison.Ordinal); + + var without = roster.People.First(person => person.Offenses is null || person.Offenses.Count == 0); + var withoutSlice = PersonJsonSlice(json, without.Id); + Assert.DoesNotContain("\"offenses\"", withoutSlice, StringComparison.Ordinal); + + var loaded = RosterJson.Parse(json).ToRoster(); + var loadedPupil = loaded.People.First(person => person.Id.Equals(pupil.Id, StringComparison.Ordinal)); + Assert.NotNull(loadedPupil.Offenses); + Assert.Single(loadedPupil.Offenses!); + Assert.Equal(OffenseKinds.Fight, loadedPupil.Offenses[0].Kind); + Assert.Equal(other.Id, loadedPupil.Offenses[0].OtherPersonId); + Assert.Equal(time, loadedPupil.Offenses[0].Time); + } + + private static string PersonJsonSlice(string json, string personId) + { + var marker = $"\"id\": \"{personId}\""; + var start = json.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var end = json.IndexOf("},", start, StringComparison.Ordinal); + if (end < 0) + { + end = json.Length; + } + + return json[start..end]; + } + + private static BehaviorDef Rules(int max) => new() + { + DefName = "Behavior", + OffenseMemoryMax = max, + OffenseMemoryKinds = BehaviorDef.DefaultOffenseMemoryKinds, + }; + + private static Person Blank(string id) + { + var cases = new CaseTable + { + Nom = id, + Gen = id, + Dat = id, + Acc = id, + Ins = id, + Pre = id, + }; + return new Person + { + Id = id, + FamilyId = "f", + Female = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName(id, id, id, cases, cases, cases), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } +} diff --git a/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj b/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj index 3dc244c..c86e111 100644 --- a/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj +++ b/tests/HSchool.Server.Tests/HSchool.Server.Tests.csproj @@ -22,4 +22,11 @@ + + + vanilla\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/tests/HSchool.Server.Tests/PersonCardReaderOffensesTests.cs b/tests/HSchool.Server.Tests/PersonCardReaderOffensesTests.cs new file mode 100644 index 0000000..92f91ab --- /dev/null +++ b/tests/HSchool.Server.Tests/PersonCardReaderOffensesTests.cs @@ -0,0 +1,69 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Server.Game; +using HSchool.Simulation; + +namespace HSchool.Server.Tests; + +public class PersonCardReaderOffensesTests +{ + private static readonly DateTime Start = new(2012, 4, 3, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Card_ReturnsOffensesInSameOrderAsPerson() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 42, "Russia", Start); + var pupil = roster.People.First(person => person.IsStudent && !person.IsParent); + var other = roster.People.First(person => + person.IsStudent && !person.Id.Equals(pupil.Id, StringComparison.Ordinal)); + var t0 = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc); + var rules = catalog.BehaviorRules!; + OffenseMemory.Record(pupil, OffenseKinds.Quarrel, t0, other.Id, rules); + OffenseMemory.Record(pupil, OffenseKinds.Fight, t0.AddMinutes(5), other.Id, rules); + + var school = School.Create(42, "OffensesCard", Start, catalog, map); + using (school) + { + school.InstallPeople(roster, 42, "Russia", ApplicantPool.Empty); + var card = PersonCardReader.Read(school, pupil.Id, "ru"); + Assert.NotNull(card); + Assert.NotNull(card!.Offenses); + Assert.Equal(2, card.Offenses!.Count); + Assert.Equal( + pupil.Offenses!.Select(row => row.Kind).ToArray(), + card.Offenses.Select(row => row.Kind).ToArray()); + Assert.Equal(OffenseKinds.Quarrel, card.Offenses[0].Kind); + Assert.Equal("ссора", card.Offenses[0].KindLabel); + Assert.Equal(other.Id, card.Offenses[0].OtherPersonId); + Assert.Equal(other.Name.Full, card.Offenses[0].OtherFullName); + Assert.Equal(OffenseKinds.Fight, card.Offenses[1].Kind); + Assert.Equal("драка", card.Offenses[1].KindLabel); + } + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + Assert.True(Directory.Exists(root), $"Vanilla pack missing at {root}."); + 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; + } + + documents.Add(new ContentDocument( + CatalogLoader.CorePackId, + Path.GetRelativePath(root, path).Replace('\\', '/'), + File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +} diff --git a/tests/HSchool.Simulation.Tests/OffenseMemorySimulationTests.cs b/tests/HSchool.Simulation.Tests/OffenseMemorySimulationTests.cs new file mode 100644 index 0000000..e3c7f81 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/OffenseMemorySimulationTests.cs @@ -0,0 +1,198 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Simulation; + +namespace HSchool.Simulation.Tests; + +public class OffenseMemorySimulationTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void FightBrokenByStaff_AddsFightAndReprimandToBothPupils() + { + var (school, first, second, staffId) = TwoPupilsAndStaffOnBreak(); + using (school) + { + OpinionStore.Set(first, second.Id, -55); + OpinionStore.Set(second, first.Id, -55); + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + PlaceAt(school, staffId, "yard"); + + Assert.True(school.TryStartAction(first.Id, TalkActions.Fight)); + PlaceAt(school, staffId, "yard"); + TalkCircleSystem.Apply(school, 1d); + + first = school.Roster!.People.First(person => person.Id == first.Id); + second = school.Roster.People.First(person => person.Id == second.Id); + AssertFightAndReprimand(first, second.Id); + AssertFightAndReprimand(second, first.Id); + } + } + + [Fact] + public void OverCeiling_InWorld_DropsOldestAfterRepeatedFights() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + var rules = school.Catalog!.BehaviorRules!; + var max = rules.OffenseMemoryMax; + Assert.True(max > 0); + + for (var i = 0; i < max + 1; i++) + { + OpinionStore.Set(first, second.Id, -55); + OpinionStore.Set(second, first.Id, -55); + PlaceAt(school, first.Id, "yard"); + PlaceAt(school, second.Id, "yard"); + Assert.True(school.TryStartAction(first.Id, TalkActions.Fight)); + TickWhile(school, personId => TalkActions.IsFight(ActivityOf(school, personId)), first.Id, second.Id); + first = school.Roster!.People.First(person => person.Id == first.Id); + second = school.Roster.People.First(person => person.Id == second.Id); + } + + Assert.NotNull(first.Offenses); + Assert.Equal(max, first.Offenses!.Count); + Assert.All(first.Offenses, row => Assert.Equal(OffenseKinds.Fight, row.Kind)); + } + } + + private static void AssertFightAndReprimand(Person person, string otherId) + { + Assert.NotNull(person.Offenses); + Assert.Contains( + person.Offenses!, + row => row.Kind == OffenseKinds.Fight && row.OtherPersonId == otherId); + Assert.Contains( + person.Offenses!, + row => row.Kind == OffenseKinds.Reprimand && row.OtherPersonId == otherId); + } + + private static (School School, Person First, Person Second) TwoPupilsOnBreak() + { + var (catalog, map) = Vanilla(); + var school = OpenSchool(catalog, map, seed: 42, TuesdayMorning, advanceToBreak: true); + var schoolClass = school.Roster!.Classes.First(row => row.RoomId == "classroom-101"); + var pupils = schoolClass.PupilIds + .Select(id => school.Roster.People.First(person => person.Id == id)) + .Take(2) + .ToArray(); + return (school, pupils[0], pupils[1]); + } + + private static (School School, Person First, Person Second, string StaffId) TwoPupilsAndStaffOnBreak() + { + var (school, first, second) = TwoPupilsOnBreak(); + const float cap = 100_000f; + var roster = school.Roster!; + var pool = school.Applicants!; + var hired = Staffing.Hire( + school.Catalog!, + school.Map!, + roster, + pool, + pool.Applicants[0].Person.Id, + Staffing.TeacherPosition, + cap); + Assert.Equal(StaffingError.None, hired.Error); + school.ApplyStaffing(hired.Roster, hired.Pool); + first = school.Roster!.People.First(person => person.Id == first.Id); + second = school.Roster.People.First(person => person.Id == second.Id); + var staffId = school.Roster.People.First(person => person.IsStaff).Id; + return (school, first, second, staffId); + } + + private static School OpenSchool(DefCatalog catalog, MapLayout map, int seed, DateTime start, bool advanceToBreak) + { + var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start); + var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start); + var schoolClass = roster.Classes.First(row => row.RoomId == "classroom-101"); + var school = School.Create(seed, "OffenseMemory", start, catalog, map); + school.InstallPeople(roster, seed, "Russia", pool); + school.SetTimetable(new HSchool.Schedule.Timetable( + [ + new HSchool.Schedule.LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), + ], + [])); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + if (advanceToBreak) + { + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc)); + } + + return school; + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static void PlaceAt(School school, string personId, string? nodeId) + { + TalkCircleSystem.Interrupt(school, personId); + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + presence = nodeId is null ? Presence.OffCampus : new Presence(nodeId, 0f, nodeId, false, []); + activity = PersonActivity.Idle; + intent = Intent.None; + } + }); + } + + private static void TickWhile(School school, Func stillGoing, params string[] personIds) + { + for (var i = 0; i < 12 && personIds.Any(stillGoing); i++) + { + school.Tick(0.2d, 5d); + } + } + + private static string? ActivityOf(School school, string personId) + { + string? found = null; + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal) && activity.IsActive) + { + found = activity.ActionId; + } + }); + return found; + } + + 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; + } + + documents.Add(new ContentDocument( + CatalogLoader.CorePackId, + Path.GetRelativePath(root, path).Replace('\\', '/'), + File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}