Remember recent quarrels, fights and reprimands on the person.
Short offense list for the pupil card and save, capped by BehaviorDef — not a discipline score.
This commit is contained in:
@@ -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] Карточка отдает тот же порядок, что в мире (не клиентская фантазия)
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('драка');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,15 @@ internal sealed record PersonCardResponse(
|
||||
PersonConnectionsResponse? Connections = null,
|
||||
DefLabelResponse? Orientation = null,
|
||||
IReadOnlyList<string>? TalkCircleMemberIds = null,
|
||||
string? TalkTopicId = null);
|
||||
string? TalkTopicId = null,
|
||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null);
|
||||
|
||||
internal sealed record OffenseEntryResponse(
|
||||
string Kind,
|
||||
string KindLabel,
|
||||
DateTime Time,
|
||||
string? OtherPersonId,
|
||||
string? OtherFullName);
|
||||
|
||||
|
||||
internal sealed record WornItemResponse(
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user