Merge branch 'phase/69-offense-memory'

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	src/HSchool.Server/Api/PeopleModels.cs
#	src/HSchool.Server/Game/PersonCardReader.cs
This commit is contained in:
Leonid Pershin
2026-08-21 09:58:31 +03:00
23 changed files with 761 additions and 17 deletions
+10 -10
View File
@@ -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] Карточка отдает тот же порядок, что в мире (не клиентская фантазия)
## Критерий готовности
+1 -1
View File
@@ -19,7 +19,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [69. Память проступков](69-offense-memory.md) | 🔄 | Короткий список на человеке и карточке |
| [69. Память проступков](69-offense-memory.md) | | Короткий список на человеке и карточке |
| [70. Вызов и очередь](70-director-summons.md) | ⬜ | Авто, ходьба, коридор, один в кабинете |
| [71. Тосты вызова](71-summons-notices.md) | ⬜ | `EventDef` info; очередь видна |
+15
View File
@@ -367,6 +367,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",
@@ -424,6 +430,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": [
+6
View File
@@ -232,6 +232,9 @@ const ru = {
peopleLogTime: 'Время',
peopleLogEvent: 'Событие',
peopleLogEmpty: 'Сегодня записей нет.',
peopleOffenses: 'Проступки',
peopleOffensesEmpty: 'Недавних проступков нет.',
peopleOffenseWith: '{kind} · {other}',
peoplePortraitAvatar: 'Аватар',
peoplePortraitFull: 'В полный рост',
@@ -635,6 +638,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',
+10
View File
@@ -357,6 +357,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 {
+24
View File
@@ -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;
@@ -395,4 +395,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('драка');
});
});
+3 -1
View File
@@ -159,7 +159,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();
}
}
+39 -3
View File
@@ -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)}`;
}
+13
View File
@@ -652,6 +652,19 @@ internal static class PeopleDefValidator
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' apologyRestoreFraction must be 01.");
}
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)
+18
View File
@@ -566,6 +566,24 @@ public sealed class BehaviorDef : Def
/// <summary>Opinion of the victim at or above this — a third person may join the quarrel.</summary>
public int DefendOpinionMin { get; init; } = 40;
/// <summary>
/// How many recent misconduct rows a person keeps. Oldest drop first. Zero disables the list.
/// </summary>
public int OffenseMemoryMax { get; init; } = 5;
/// <summary>
/// Kind ids that may be appended (<c>quarrel</c>, <c>fight</c>, <c>reprimand</c>, …).
/// Empty falls back to <see cref="DefaultOffenseMemoryKinds"/>.
/// </summary>
public IReadOnlyList<string> OffenseMemoryKinds { get; init; } = DefaultOffenseMemoryKinds;
public static IReadOnlyList<string> DefaultOffenseMemoryKinds { get; } =
[
"quarrel",
"fight",
"reprimand",
];
public static IReadOnlyList<OpinionBand> DefaultOpinionBands { get; } =
[
new() { Min = 70, Id = "OpinionCloseFriend" },
+103
View File
@@ -0,0 +1,103 @@
using System.Text.Json.Serialization;
using HSchool.Content;
namespace HSchool.People;
/// <summary>Stable kind ids written into <see cref="OffenseRecord.Kind"/> and listed on <see cref="BehaviorDef.OffenseMemoryKinds"/>.</summary>
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..],
};
}
/// <summary>One remembered misconduct. Sparse list on the person — not a discipline score.</summary>
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; }
}
/// <summary>Appends recent offenses and drops the oldest when over the BehaviorDef ceiling.</summary>
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<string> 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;
}
}
+6
View File
@@ -69,6 +69,12 @@ public sealed record Person
/// </summary>
public string? BullyVictimId { get; set; }
/// <summary>
/// Recent misconduct (quarrel / fight / reprimand). Null when empty so people.json stays compact.
/// Ceiling and which kinds write live on <c>BehaviorDef</c>.
/// </summary>
public List<OffenseRecord>? Offenses { get; set; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
+9 -1
View File
@@ -87,7 +87,15 @@ internal sealed record PersonCardResponse(
IReadOnlyList<string>? TalkCircleMemberIds = null,
string? TalkTopicId = null,
string? ClassTeacherId = null,
string? ClassTeacherName = null);
string? ClassTeacherName = null,
IReadOnlyList<OffenseEntryResponse>? Offenses = null);
internal sealed record OffenseEntryResponse(
string Kind,
string KindLabel,
DateTime Time,
string? OtherPersonId,
string? OtherFullName);
internal sealed record WornItemResponse(
+43 -1
View File
@@ -108,7 +108,49 @@ internal static partial class PersonCardReader
TalkCircleMemberIds: circle?.MemberIds ?? [],
TalkTopicId: circle?.TopicId,
ClassTeacherId: classTeacherId,
ClassTeacherName: classTeacherName);
ClassTeacherName: classTeacherName,
Offenses: Offenses(roster, person, catalog, locale));
}
private static IReadOnlyList<OffenseEntryResponse> 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<string, float>? LiveNeeds(World world, string personId)
@@ -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"],
}
@@ -208,6 +208,9 @@
"Fought": "fought",
"Apologized": "apologized",
"Reprimanded": "reprimanded",
"OffenseQuarrel": "quarrel",
"OffenseFight": "fight",
"OffenseReprimand": "reprimand",
"TopicStudy": "schoolwork",
"TopicGames": "games",
"TopicFood": "food",
@@ -208,6 +208,9 @@
"Fought": "дрались",
"Apologized": "извинился",
"Reprimanded": "выговор",
"OffenseQuarrel": "ссора",
"OffenseFight": "драка",
"OffenseReprimand": "выговор",
"TopicStudy": "учёбе",
"TopicGames": "играх",
"TopicFood": "еде",
@@ -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<Person> 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,
@@ -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<string, int>(StringComparer.Ordinal),
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
Traits = [],
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
};
}
}
@@ -22,4 +22,11 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -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<ContentDocument>();
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);
}
}
@@ -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<PersonIdentity, Presence, PersonActivity, Intent>();
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<string, bool> 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<PersonIdentity, PersonActivity>();
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<ContentDocument>();
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);
}
}