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:
Leonid Pershin
2026-08-21 09:37:12 +03:00
parent 910ba4da2d
commit fdcaed0f74
22 changed files with 760 additions and 16 deletions
+6
View File
@@ -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',
+10
View File
@@ -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 {
+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;
@@ -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('драка');
});
});
+3 -1
View File
@@ -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();
}
}
+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
@@ -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(
+43 -1
View File
@@ -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,