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:
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('драка');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user