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);
+ }
+}