diff --git a/docs/phases/09-social/46-speech-home.md b/docs/phases/09-social/46-speech-home.md index 25b68bb..f93af25 100644 --- a/docs/phases/09-social/46-speech-home.md +++ b/docs/phases/09-social/46-speech-home.md @@ -13,20 +13,20 @@ ## Задачи -- [ ] В «Правилах» два комбобокса речи: ученики / сотрудники × свободно / без грубого / только учёба -- [ ] В силу с завтра; API сегодня не переодевает темы текущих кружков -- [ ] Система не стартует кружок с темой вне политики; начатый вчера доигрывает -- [ ] Одно семейное событие на человека за рабочее утро; skip ночи применяет -- [ ] Семейные темы из `TopicDef`; сдвиг меньше школьного разговора -- [ ] `docs/protocol.md` — HTTP правил речи +- [x] В «Правилах» два комбобокса речи: ученики / сотрудники × свободно / без грубого / только учёба +- [x] В силу с завтра; API сегодня не переодевает темы текущих кружков +- [x] Система не стартует кружок с темой вне политики; начатый вчера доигрывает +- [x] Одно семейное событие на человека за рабочее утро; skip ночи применяет +- [x] Семейные темы из `TopicDef`; сдвиг меньше школьного разговора +- [x] `docs/protocol.md` — HTTP правил речи ## Тесты, без которых фаза не закрыта -- [ ] POST «только учёба» сегодня не мешает текущему кружку «игры» -- [ ] На следующее рабочее утро грубая тема у учеников не стартует -- [ ] Skip с пятницы на понедельник применяет домашнее событие и дрейф мнений; мнение с +- [x] POST «только учёба» сегодня не мешает текущему кружку «игры» +- [x] На следующее рабочее утро грубая тема у учеников не стартует +- [x] Skip с пятницы на понедельник применяет домашнее событие и дрейф мнений; мнение с родителем сдвинуто, брошенная дружба ближе к нулю -- [ ] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра» +- [x] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра» ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index bceecee..271ba71 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -572,6 +572,36 @@ omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`. { "students": { "form": "strict", "color": "noBright" } } ``` +### `GET /api/schools/{id}/speech-rules` + +Student and staff speech-topic policy for the school. Only the owner may read this (`403` +`not-owner`). Unknown `{id}` is `404` `unknown-school`. + +Policy is one of `free`, `noRude`, `studyOnly`. When a `POST` has been accepted but not yet +applied, `pendingStudents` and/or `pendingStaff` show what takes effect on the **next work +morning** — the same morning as dress rules. Today's live circles keep their topic; the system +does not start a new circle whose topic is outside the live policy. + +```json +{ + "students": "free", + "staff": "free", + "pendingStudents": null, + "pendingStaff": null +} +``` + +### `POST /api/schools/{id}/speech-rules` + +Queues a change for the next work morning. Only the owner may post (`403` `not-owner`). Either or +both of `students` and `staff` may be sent; omitted sides keep their current pending/live rule. +Unknown `{id}` is `404` `unknown-school`. Unknown policy is `400` `unknown-speech`. Response body +matches `GET`. + +```json +{ "students": "studyOnly" } +``` + ### `GET /api/schools/{id}/staffing` Money, uncovered subjects, the applicant pool and current staff. Only the owner may read this diff --git a/src/HSchool.Ai/TalkCircles.cs b/src/HSchool.Ai/TalkCircles.cs index cd6702f..9c5dead 100644 --- a/src/HSchool.Ai/TalkCircles.cs +++ b/src/HSchool.Ai/TalkCircles.cs @@ -200,7 +200,8 @@ public static class TalkCircles Person picker, int age, int seed, - bool whisperOnLesson = false) + bool whisperOnLesson = false, + string? speechPolicy = null) { var candidates = new List<(TopicDef Topic, float Weight)>(); foreach (var topic in catalog.Topics.Values) @@ -208,7 +209,8 @@ public static class TalkCircles if (topic.Abstract || !RoleFitsTopic(topic, picker.IsStudent, picker.IsStaff, picker.IsParent) || !AgeFits(topic, age) - || (whisperOnLesson && !topic.WhisperOnLesson)) + || (whisperOnLesson && !topic.WhisperOnLesson) + || !SpeechPolicies.Allows(speechPolicy ?? SpeechPolicies.Free, topic)) { continue; } @@ -253,6 +255,21 @@ public static class TalkCircles return candidates[^1].Topic.DefName; } + /// Family-morning topics: tag only, no school role or speech-policy filter. + public static string? PickTaggedTopic(DefCatalog catalog, string tag, int seed) + { + var candidates = catalog.Topics.Values + .Where(topic => !topic.Abstract && topic.Tags.Contains(tag, StringComparer.Ordinal)) + .OrderBy(topic => topic.DefName, StringComparer.Ordinal) + .ToArray(); + if (candidates.Length == 0) + { + return null; + } + + return candidates[(seed & int.MaxValue) % candidates.Length].DefName; + } + /// Best shared language skill id among participants, or null. public static string? SharedLanguage( DefCatalog catalog, diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 91aa25b..3c7887c 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -315,11 +315,17 @@ const ru = { rulesStudentsColor: 'Цвет учеников', rulesStaffForm: 'Форма сотрудников', rulesStaffColor: 'Цвет сотрудников', - rulesRoleSummary: '{role}: {form}, {color}', + rulesRoleSummary: '{role}: {form}, {color}; {speech}', rulesApply: 'Применить', rulesLoadFailed: 'Не удалось загрузить правила одежды.', rulesSaveFailed: 'Не удалось сохранить правила.', rulesPickHint: 'Правила действуют на всю школу.', + rulesStudentsSpeech: 'Речь учеников', + rulesStaffSpeech: 'Речь сотрудников', + speechPolicyFree: 'свободно', + speechPolicyNoRude: 'без грубого', + speechPolicyStudyOnly: 'только учёба', + speechErrorUnknownPolicy: 'Такой политики речи нет.', dressFormRegular: 'обычная', dressFormShort: 'короткая', dressFormStrict: 'строгая', @@ -688,11 +694,17 @@ const en: Messages = { rulesStudentsColor: 'Student colours', rulesStaffForm: 'Staff form', rulesStaffColor: 'Staff colours', - rulesRoleSummary: '{role}: {form}, {color}', + rulesRoleSummary: '{role}: {form}, {color}; {speech}', rulesApply: 'Apply', rulesLoadFailed: 'Could not load dress rules.', rulesSaveFailed: 'Could not save the rules.', rulesPickHint: 'Rules apply to the whole school.', + rulesStudentsSpeech: 'Student speech', + rulesStaffSpeech: 'Staff speech', + speechPolicyFree: 'free', + speechPolicyNoRude: 'no rude talk', + speechPolicyStudyOnly: 'study only', + speechErrorUnknownPolicy: 'That speech policy is not in the catalog.', dressFormRegular: 'regular', dressFormShort: 'short', dressFormStrict: 'strict', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index abb09c1..fc8f8ff 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -762,6 +762,28 @@ export async function setDressRules( }); } +export interface SpeechRules { + readonly students: string; + readonly staff: string; + readonly pendingStudents: string | null; + readonly pendingStaff: string | null; +} + +export async function fetchSpeechRules(schoolId: number): Promise { + return request(`/api/schools/${schoolId}/speech-rules`); +} + +export async function setSpeechRules( + schoolId: number, + body: { readonly students?: string; readonly staff?: string }, +): Promise { + return request(`/api/schools/${schoolId}/speech-rules`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + export interface TimetableLesson { readonly classId: string; readonly classYear: number; diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.test.ts b/src/HSchool.Client/src/ui/dressRulesPanel.test.ts index 635ae45..39bd4eb 100644 --- a/src/HSchool.Client/src/ui/dressRulesPanel.test.ts +++ b/src/HSchool.Client/src/ui/dressRulesPanel.test.ts @@ -5,8 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, fetchDressRules, + fetchSpeechRules, setDressRules, + setSpeechRules, type DressRules, + type SpeechRules, } from '../net/api.ts'; import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; @@ -17,7 +20,9 @@ vi.mock('../net/api.ts', async (importOriginal) => { return { ...actual, fetchDressRules: vi.fn(), + fetchSpeechRules: vi.fn(), setDressRules: vi.fn(), + setSpeechRules: vi.fn(), }; }); @@ -32,12 +37,24 @@ function rules(): DressRules { }; } +function speech(): SpeechRules { + return { + students: 'free', + staff: 'free', + pendingStudents: null, + pendingStaff: null, + }; +} + describe('DressRulesPanel', () => { beforeEach(() => { setLocale('en'); vi.mocked(fetchDressRules).mockReset(); + vi.mocked(fetchSpeechRules).mockReset(); vi.mocked(setDressRules).mockReset(); + vi.mocked(setSpeechRules).mockReset(); vi.mocked(fetchDressRules).mockResolvedValue(rules()); + vi.mocked(fetchSpeechRules).mockResolvedValue(speech()); }); afterEach(() => { @@ -49,7 +66,7 @@ describe('DressRulesPanel', () => { const panel = new DressRulesPanel(); document.body.append(panel.element); panel.show(3); - await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled()); + await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled()); const colorSelect = panel.element.querySelector('select.input'); if (!(colorSelect instanceof HTMLSelectElement)) { @@ -66,7 +83,13 @@ describe('DressRulesPanel', () => { const panel = new DressRulesPanel(); document.body.append(panel.element); panel.show(3); - await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled()); + await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled()); + const apply = panel.element.querySelector('button.button'); + if (!(apply instanceof HTMLButtonElement)) { + throw new Error('apply button is missing'); + } + + await vi.waitFor(() => expect(apply.disabled).toBe(false)); const formSelect = panel.element.querySelector('fieldset select.input'); if (!(formSelect instanceof HTMLSelectElement)) { @@ -74,11 +97,6 @@ describe('DressRulesPanel', () => { } formSelect.value = 'strict'; - const apply = panel.element.querySelector('button.button'); - if (!(apply instanceof HTMLButtonElement)) { - throw new Error('apply button is missing'); - } - apply.click(); const banner = panel.element.querySelector('.staffing__error'); if (!(banner instanceof HTMLElement)) { @@ -88,4 +106,40 @@ describe('DressRulesPanel', () => { await vi.waitFor(() => expect(banner.hidden).toBe(false)); expect(banner.textContent).toBe(t('dressErrorUnknownForm')); }); + + it('places speech comboboxes next to dress with a from-tomorrow caption', async () => { + const panel = new DressRulesPanel(); + document.body.append(panel.element); + panel.reveal(); + panel.show(3); + await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled()); + + expect(panel.element.textContent).toContain(t('rulesPendingTitle')); + const studentsFieldset = panel.element.querySelector('fieldset.rules__fieldset'); + if (!(studentsFieldset instanceof HTMLFieldSetElement)) { + throw new Error('students fieldset is missing'); + } + + const selects = [...studentsFieldset.querySelectorAll('select.input')]; + expect(selects).toHaveLength(3); + const speechSelect = studentsFieldset.querySelector('select[data-speech="students"]'); + if (!(speechSelect instanceof HTMLSelectElement)) { + throw new Error('student speech select is missing'); + } + + expect(selects[2]).toBe(speechSelect); + expect([...speechSelect.options].map((option) => option.value)).toEqual([ + 'free', + 'noRude', + 'studyOnly', + ]); + expect(studentsFieldset.textContent).toContain(t('rulesStudentsSpeech')); + + const staffSpeech = panel.element.querySelector('select[data-speech="staff"]'); + if (!(staffSpeech instanceof HTMLSelectElement)) { + throw new Error('staff speech select is missing'); + } + + expect(staffSpeech.closest('fieldset.rules__fieldset')?.querySelectorAll('select.input')).toHaveLength(3); + }); }); diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.ts b/src/HSchool.Client/src/ui/dressRulesPanel.ts index e53fdae..8aa19c1 100644 --- a/src/HSchool.Client/src/ui/dressRulesPanel.ts +++ b/src/HSchool.Client/src/ui/dressRulesPanel.ts @@ -1,4 +1,4 @@ -import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts'; +import { fetchDressRules, fetchSpeechRules, setDressRules, setSpeechRules, type DressRulePair, type DressRules, type SpeechRules } from '../net/api.ts'; import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { @@ -7,6 +7,8 @@ import { dressFormLabel, dressFormOptions, dressRulesError, + speechPolicyLabel, + speechPolicyOptions, } from './dressRulesUi.ts'; import { fillSelect } from './staffingUi.ts'; @@ -26,10 +28,20 @@ export class DressRulesPanel { private readonly studentsFormSelect = el('select', { class: 'input people__input' }); private readonly studentsColorLabel = el('span', { class: 'people__label' }); private readonly studentsColorSelect = el('select', { class: 'input people__input' }); + private readonly studentsSpeechLabel = el('span', { class: 'people__label' }); + private readonly studentsSpeechSelect = el('select', { + class: 'input people__input', + dataset: { speech: 'students' }, + }); private readonly staffFormLabel = el('span', { class: 'people__label' }); private readonly staffFormSelect = el('select', { class: 'input people__input' }); private readonly staffColorLabel = el('span', { class: 'people__label' }); private readonly staffColorSelect = el('select', { class: 'input people__input' }); + private readonly staffSpeechLabel = el('span', { class: 'people__label' }); + private readonly staffSpeechSelect = el('select', { + class: 'input people__input', + dataset: { speech: 'staff' }, + }); private readonly applyButton = el('button', { class: 'button', type: 'button' }); private readonly studentsLegend: HTMLElement; @@ -37,6 +49,7 @@ export class DressRulesPanel { private schoolId: number | null = null; private rules: DressRules | null = null; + private speech: SpeechRules | null = null; private loadToken = 0; private busy = false; @@ -70,6 +83,12 @@ export class DressRulesPanel { this.studentsColorLabel, this.studentsColorSelect, ), + el( + 'label', + { class: 'people__field' }, + this.studentsSpeechLabel, + this.studentsSpeechSelect, + ), ), el( 'fieldset', @@ -77,6 +96,7 @@ export class DressRulesPanel { el('legend', { class: 'people__section-title', text: '' }), el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect), el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect), + el('label', { class: 'people__field' }, this.staffSpeechLabel, this.staffSpeechSelect), ), this.applyButton, ), @@ -100,15 +120,19 @@ export class DressRulesPanel { this.pendingHint.textContent = t('rulesPendingHint'); this.studentsFormLabel.textContent = t('rulesStudentsForm'); this.studentsColorLabel.textContent = t('rulesStudentsColor'); + this.studentsSpeechLabel.textContent = t('rulesStudentsSpeech'); this.staffFormLabel.textContent = t('rulesStaffForm'); this.staffColorLabel.textContent = t('rulesStaffColor'); + this.staffSpeechLabel.textContent = t('rulesStaffSpeech'); this.applyButton.textContent = t('rulesApply'); this.studentsLegend.textContent = t('rulesStudentsRole'); this.staffLegend.textContent = t('rulesStaffRole'); fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value); fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value); + fillSelect(this.studentsSpeechSelect, speechPolicyOptions(), this.studentsSpeechSelect.value); fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value); fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value); + fillSelect(this.staffSpeechSelect, speechPolicyOptions(), this.staffSpeechSelect.value); this.paint(); } @@ -116,6 +140,7 @@ export class DressRulesPanel { if (this.schoolId !== schoolId) { this.schoolId = schoolId; this.rules = null; + this.speech = null; this.clearError(); } @@ -138,12 +163,13 @@ export class DressRulesPanel { const token = ++this.loadToken; try { - const rules = await fetchDressRules(schoolId); + const [rules, speech] = await Promise.all([fetchDressRules(schoolId), fetchSpeechRules(schoolId)]); if (token !== this.loadToken) { return; } this.rules = rules; + this.speech = speech; this.paint(); } catch { if (token !== this.loadToken) { @@ -156,9 +182,10 @@ export class DressRulesPanel { private paint(): void { const rules = this.rules; - this.applyButton.disabled = this.busy || rules === null; + const speech = this.speech; + this.applyButton.disabled = this.busy || rules === null || speech === null; - if (rules === null) { + if (rules === null || speech === null) { this.currentStudents.textContent = '—'; this.currentStaff.textContent = '—'; return; @@ -168,11 +195,13 @@ export class DressRulesPanel { role: t('rulesStudentsRole'), form: dressFormLabel(rules.students.form), color: dressColorLabel(rules.students.color), + speech: speechPolicyLabel(speech.students), }); this.currentStaff.textContent = t('rulesRoleSummary', { role: t('rulesStaffRole'), form: dressFormLabel(rules.staff.form), color: dressColorLabel(rules.staff.color), + speech: speechPolicyLabel(speech.staff), }); const pendingStudents = rules.pendingStudents ?? rules.students; @@ -181,12 +210,19 @@ export class DressRulesPanel { fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color); fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form); fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color); + fillSelect( + this.studentsSpeechSelect, + speechPolicyOptions(), + speech.pendingStudents ?? speech.students, + ); + fillSelect(this.staffSpeechSelect, speechPolicyOptions(), speech.pendingStaff ?? speech.staff); } private async apply(): Promise { const schoolId = this.schoolId; const rules = this.rules; - if (schoolId === null || rules === null || this.busy) { + const speech = this.speech; + if (schoolId === null || rules === null || speech === null || this.busy) { return; } @@ -201,7 +237,21 @@ export class DressRulesPanel { body.staff = staff; } - if (body.students === undefined && body.staff === undefined) { + const speechBody: { students?: string; staff?: string } = {}; + if (this.studentsSpeechSelect.value !== (speech.pendingStudents ?? speech.students)) { + speechBody.students = this.studentsSpeechSelect.value; + } + + if (this.staffSpeechSelect.value !== (speech.pendingStaff ?? speech.staff)) { + speechBody.staff = this.staffSpeechSelect.value; + } + + if ( + body.students === undefined + && body.staff === undefined + && speechBody.students === undefined + && speechBody.staff === undefined + ) { return; } @@ -209,7 +259,14 @@ export class DressRulesPanel { this.clearError(); this.paint(); try { - this.rules = await setDressRules(schoolId, body); + if (body.students !== undefined || body.staff !== undefined) { + this.rules = await setDressRules(schoolId, body); + } + + if (speechBody.students !== undefined || speechBody.staff !== undefined) { + this.speech = await setSpeechRules(schoolId, speechBody); + } + this.paint(); } catch (error) { this.showError(dressRulesError(error)); diff --git a/src/HSchool.Client/src/ui/dressRulesUi.ts b/src/HSchool.Client/src/ui/dressRulesUi.ts index b9fbbd2..3541ef8 100644 --- a/src/HSchool.Client/src/ui/dressRulesUi.ts +++ b/src/HSchool.Client/src/ui/dressRulesUi.ts @@ -51,7 +51,30 @@ export function dressRulesError(error: unknown): string { return t('dressErrorUnknownForm'); case 'unknown-color': return t('dressErrorUnknownColor'); + case 'unknown-speech': + return t('speechErrorUnknownPolicy'); default: return error.message.length > 0 ? error.message : t('rulesSaveFailed'); } } + +export const SPEECH_POLICIES = ['free', 'noRude', 'studyOnly'] as const; + +export type SpeechPolicy = (typeof SPEECH_POLICIES)[number]; + +export function speechPolicyOptions(): readonly { value: SpeechPolicy; label: string }[] { + return SPEECH_POLICIES.map((value) => ({ value, label: speechPolicyLabel(value) })); +} + +export function speechPolicyLabel(policy: string): string { + switch (policy) { + case 'free': + return t('speechPolicyFree'); + case 'noRude': + return t('speechPolicyNoRude'); + case 'studyOnly': + return t('speechPolicyStudyOnly'); + default: + return policy; + } +} diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index f2b2238..fcbbfe5 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -7,6 +7,7 @@ import { fetchPerson, fetchGameStatus, fetchDressRules, + fetchSpeechRules, fetchStaffing, fetchTimetable, hireStaff, @@ -27,6 +28,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { fetchStaffing: vi.fn(), fetchTimetable: vi.fn(), fetchDressRules: vi.fn(), + fetchSpeechRules: vi.fn(), fetchPerson: vi.fn(), fetchGameStatus: vi.fn().mockResolvedValue({ tick: 0, @@ -224,6 +226,7 @@ describe('ManagementPanel uncovered', () => { vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); vi.mocked(fetchDressRules).mockReset(); + vi.mocked(fetchSpeechRules).mockReset(); vi.mocked(fetchStaffing).mockResolvedValue({ ...staffing(), applicants: [], @@ -245,6 +248,12 @@ describe('ManagementPanel uncovered', () => { pendingStudents: null, pendingStaff: null, }); + vi.mocked(fetchSpeechRules).mockResolvedValue({ + students: 'free', + staff: 'free', + pendingStudents: null, + pendingStaff: null, + }); }); afterEach(() => { @@ -271,6 +280,7 @@ describe('ManagementPanel rules tab', () => { vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); vi.mocked(fetchDressRules).mockReset(); + vi.mocked(fetchSpeechRules).mockReset(); vi.mocked(fetchStaffing).mockResolvedValue(staffing()); vi.mocked(fetchTimetable).mockResolvedValue(timetable()); vi.mocked(fetchDressRules).mockResolvedValue({ @@ -279,6 +289,12 @@ describe('ManagementPanel rules tab', () => { pendingStudents: null, pendingStaff: null, }); + vi.mocked(fetchSpeechRules).mockResolvedValue({ + students: 'free', + staff: 'free', + pendingStudents: null, + pendingStaff: null, + }); }); afterEach(() => { diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 61cd49a..8bcd7fb 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -472,6 +472,12 @@ public sealed class BehaviorDef : Def /// Points each work morning moves an opinion toward family basis or zero. public int OpinionDriftPerMorning { get; init; } = 3; + /// + /// Family-morning opinion uses this fraction of the topic's school-talk shift. Below 1 so + /// breakfast is quieter than a corridor circle. + /// + public float HomeTalkOpinionScale { get; init; } = 0.5f; + /// How many non-family friends or enemies the card lists at the top. public int OpinionTopCount { get; init; } = 5; diff --git a/src/HSchool.Content/SpeechRules.cs b/src/HSchool.Content/SpeechRules.cs new file mode 100644 index 0000000..253d0bc --- /dev/null +++ b/src/HSchool.Content/SpeechRules.cs @@ -0,0 +1,68 @@ +namespace HSchool.Content; + +/// School speech-topic strictness. Values are wire and save ids. +public static class SpeechPolicies +{ + public const string Free = "free"; + public const string NoRude = "noRude"; + public const string StudyOnly = "studyOnly"; + + public static readonly IReadOnlyList All = [Free, NoRude, StudyOnly]; + + public static bool IsKnown(string value) => + All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal)); + + /// + /// Whether this topic may start under the live policy. Already-running circles are not filtered. + /// + public static bool Allows(string policy, TopicDef topic) + { + ArgumentNullException.ThrowIfNull(topic); + if (policy.Equals(StudyOnly, StringComparison.Ordinal)) + { + return topic.Tags.Contains(TopicTags.Study, StringComparer.Ordinal); + } + + if (policy.Equals(NoRude, StringComparison.Ordinal)) + { + return !topic.Tags.Contains(TopicTags.Rude, StringComparer.Ordinal); + } + + return true; + } +} + +/// Live speech rules plus optional next-day overrides waiting for a work morning. +public sealed record SchoolSpeechRules +{ + public string Students { get; init; } = SpeechPolicies.Free; + + public string Staff { get; init; } = SpeechPolicies.Free; + + public string? PendingStudents { get; init; } + + public string? PendingStaff { get; init; } + + public string For(bool student) => student ? Students : Staff; + + public SchoolSpeechRules WithPending(string? students, string? staff) => + this with { PendingStudents = students, PendingStaff = staff }; + + public SchoolSpeechRules ApplyPending() + { + var next = this; + if (PendingStudents is { } students) + { + next = next with { Students = students, PendingStudents = null }; + } + + if (PendingStaff is { } staff) + { + next = next with { Staff = staff, PendingStaff = null }; + } + + return next; + } + + public bool HasPending => PendingStudents is not null || PendingStaff is not null; +} diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 41d6186..86f37e2 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -20,6 +20,7 @@ public static class Seed public const int WhisperSalt = 12; public const int OrientationSalt = 13; public const int ConflictSalt = 14; + public const int HomeSalt = 15; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index e64197f..9fabc51 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -494,6 +494,81 @@ internal static class SchoolEndpoints }) .WithName("SetSchoolDressRules"); + schools.MapGet("/{id:int}/speech-rules", async ( + int id, + HttpContext context, + SessionService sessions, + GameLoopService loop, + GameCommandQueue commands, + CancellationToken cancellationToken) => + { + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser)) + { + return Results.Unauthorized(); + } + + var denied = SchoolAccess.RequireManage(loop, id, normalizedUser); + if (denied is not null) + { + return denied; + } + + var command = new GameCommand.GetSpeechRules(id, NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return SpeechRulesHttp(outcome); + }) + .WithName("GetSchoolSpeechRules"); + + schools.MapPost("/{id:int}/speech-rules", async ( + int id, + SetSpeechRulesRequest request, + HttpContext context, + SessionService sessions, + GameLoopService loop, + GameCommandQueue commands, + CancellationToken cancellationToken) => + { + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser)) + { + return Results.Unauthorized(); + } + + var denied = SchoolAccess.RequireManage(loop, id, normalizedUser); + if (denied is not null) + { + return denied; + } + + string? students = null; + if (request.Students is { } studentValue) + { + if (!SpeechRulesValidation.TryParse(studentValue, out var parsed, out var studentError)) + { + return SpeechRulesBadRequest(studentError); + } + + students = parsed; + } + + string? staff = null; + if (request.Staff is { } staffValue) + { + if (!SpeechRulesValidation.TryParse(staffValue, out var parsed, out var staffError)) + { + return SpeechRulesBadRequest(staffError); + } + + staff = parsed; + } + + var command = new GameCommand.SetSpeechRules(id, students, staff, NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return SpeechRulesHttp(outcome); + }) + .WithName("SetSchoolSpeechRules"); + schools.MapGet("/{id:int}/staffing", ( int id, string? lang, @@ -937,6 +1012,27 @@ internal static class SchoolEndpoints "That colour policy is not recognized."), _ => Problem(StatusCodes.Status400BadRequest, "invalid-query", "The dress rules request is not valid."), }; + + private static IResult SpeechRulesHttp(SpeechRulesOutcome outcome) => + outcome.Error switch + { + SpeechRulesError.None => Results.Ok(outcome.Rules), + SpeechRulesError.UnknownSchool => Problem( + StatusCodes.Status404NotFound, + "unknown-school", + "That school does not exist."), + _ => SpeechRulesBadRequest(outcome.Error), + }; + + private static IResult SpeechRulesBadRequest(SpeechRulesError error) => + error switch + { + SpeechRulesError.UnknownPolicy => Problem( + StatusCodes.Status400BadRequest, + "unknown-speech", + "That speech policy is not recognized."), + _ => Problem(StatusCodes.Status400BadRequest, "invalid-query", "The speech rules request is not valid."), + }; } /// Body of POST /api/schools. The start date is a game calendar date, not a real one. diff --git a/src/HSchool.Server/Api/SpeechRulesModels.cs b/src/HSchool.Server/Api/SpeechRulesModels.cs new file mode 100644 index 0000000..d0c299f --- /dev/null +++ b/src/HSchool.Server/Api/SpeechRulesModels.cs @@ -0,0 +1,47 @@ +using HSchool.Content; + +namespace HSchool.Server.Api; + +internal sealed record SpeechRulesResponse( + string Students, + string Staff, + string? PendingStudents, + string? PendingStaff) +{ + public static SpeechRulesResponse From(SchoolSpeechRules rules) => + new(rules.Students, rules.Staff, rules.PendingStudents, rules.PendingStaff); +} + +internal sealed record SetSpeechRulesRequest(string? Students, string? Staff); + +internal enum SpeechRulesError +{ + None, + UnknownSchool, + UnknownPolicy, +} + +internal sealed record SpeechRulesOutcome(SpeechRulesError Error, SpeechRulesResponse? Rules) +{ + public static SpeechRulesOutcome Ok(SchoolSpeechRules rules) => + new(SpeechRulesError.None, SpeechRulesResponse.From(rules)); + + public static SpeechRulesOutcome Fail(SpeechRulesError error) => new(error, null); +} + +internal static class SpeechRulesValidation +{ + public static bool TryParse(string value, out string policy, out SpeechRulesError error) + { + if (!SpeechPolicies.IsKnown(value)) + { + policy = ""; + error = SpeechRulesError.UnknownPolicy; + return false; + } + + policy = value; + error = SpeechRulesError.None; + return true; + } +} diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 4158b3b..ce5cc13 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -113,4 +113,14 @@ internal abstract record GameCommand DressRulePair? PendingStudents, DressRulePair? PendingStaff, TaskCompletionSource Result) : GameCommand; + + internal sealed record GetSpeechRules( + int SchoolId, + TaskCompletionSource Result) : GameCommand; + + internal sealed record SetSpeechRules( + int SchoolId, + string? PendingStudents, + string? PendingStaff, + TaskCompletionSource Result) : GameCommand; } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index f857e3c..d8937e2 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -290,6 +290,17 @@ internal sealed class GameLoopService( new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result), setRules.Result); break; + + case GameCommand.GetSpeechRules getSpeech: + HandleSpeechRules(getSpeech.SchoolId, new WorkerCommand.GetSpeechRules(getSpeech.Result), getSpeech.Result); + break; + + case GameCommand.SetSpeechRules setSpeech: + HandleSpeechRules( + setSpeech.SchoolId, + new WorkerCommand.SetSpeechRules(setSpeech.PendingStudents, setSpeech.PendingStaff, setSpeech.Result), + setSpeech.Result); + break; } } @@ -328,6 +339,14 @@ internal sealed class GameLoopService( } } + private void HandleSpeechRules(int schoolId, WorkerCommand command, TaskCompletionSource result) + { + if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) + { + result.TrySetResult(SpeechRulesOutcome.Fail(SpeechRulesError.UnknownSchool)); + } + } + private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) @@ -700,6 +719,7 @@ internal sealed class GameLoopService( createSeed: null, save.Presence, save.DressRules, + save.SpeechRules, save.Owner, save.PortraitSettings); worker.Start(); @@ -758,6 +778,7 @@ internal sealed class GameLoopService( int? createSeed = null, IReadOnlyList? presence = null, SchoolDressRules? dressRules = null, + SchoolSpeechRules? speechRules = null, string? owner = null, SwarmUiConfigFile? portraitSettings = null) => new( @@ -775,6 +796,7 @@ internal sealed class GameLoopService( createSeed, presence, dressRules, + speechRules, owner, portraitSettings, _options, diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 625d1ef..20ec7fa 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -36,6 +36,8 @@ internal sealed class SchoolSave public SchoolDressRules? DressRules { get; init; } + public SchoolSpeechRules? SpeechRules { get; init; } + /// Normalized player name. Missing or blank means ownerless. public string? Owner { get; init; } @@ -179,6 +181,7 @@ internal sealed class SchoolStore NativeLanguage = save.NativeLanguage, Presence = save.Presence, DressRules = save.DressRules, + SpeechRules = save.SpeechRules, Owner = save.Owner, PortraitSettings = save.PortraitSettings, }); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 65d9829..be9cfb6 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -38,6 +38,7 @@ internal sealed class SchoolWorker private readonly int? _createSeed; private readonly IReadOnlyList? _savedPresence; private readonly SchoolDressRules? _savedDressRules; + private readonly SchoolSpeechRules? _savedSpeechRules; private readonly string? _owner; private readonly SwarmUiConfigFile? _portraitSettings; private readonly Action _onFailed; @@ -76,6 +77,7 @@ internal sealed class SchoolWorker int? createSeed, IReadOnlyList? savedPresence, SchoolDressRules? savedDressRules, + SchoolSpeechRules? savedSpeechRules, string? owner, SwarmUiConfigFile? portraitSettings, SimulationOptions options, @@ -100,6 +102,7 @@ internal sealed class SchoolWorker _createSeed = createSeed; _savedPresence = savedPresence; _savedDressRules = savedDressRules; + _savedSpeechRules = savedSpeechRules; _owner = string.IsNullOrWhiteSpace(owner) ? null : owner; _portraitSettings = portraitSettings; _options = options; @@ -261,6 +264,7 @@ internal sealed class SchoolWorker _school = school; school.DressRules = _savedDressRules ?? new SchoolDressRules(); + school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules(); PublishSnapshot(); if (_isNew) @@ -494,6 +498,29 @@ internal sealed class SchoolWorker dirty = true; break; } + + case WorkerCommand.GetSpeechRules getSpeech: + getSpeech.Result.TrySetResult(SpeechRulesOutcome.Ok(school.SpeechRules)); + break; + + case WorkerCommand.SetSpeechRules setSpeech: + { + var next = school.SpeechRules; + if (setSpeech.PendingStudents is { } students) + { + next = next with { PendingStudents = students }; + } + + if (setSpeech.PendingStaff is { } staff) + { + next = next with { PendingStaff = staff }; + } + + school.SpeechRules = next; + setSpeech.Result.TrySetResult(SpeechRulesOutcome.Ok(school.SpeechRules)); + dirty = true; + break; + } } } catch (Exception ex) @@ -553,6 +580,12 @@ internal sealed class SchoolWorker case WorkerCommand.SetDressRules setRules: setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool)); break; + case WorkerCommand.GetSpeechRules getSpeech: + getSpeech.Result.TrySetResult(SpeechRulesOutcome.Fail(SpeechRulesError.UnknownSchool)); + break; + case WorkerCommand.SetSpeechRules setSpeech: + setSpeech.Result.TrySetResult(SpeechRulesOutcome.Fail(SpeechRulesError.UnknownSchool)); + break; } } @@ -590,6 +623,12 @@ internal sealed class SchoolWorker case WorkerCommand.SetDressRules setRules: setRules.Result.TrySetException(exception); break; + case WorkerCommand.GetSpeechRules getSpeech: + getSpeech.Result.TrySetException(exception); + break; + case WorkerCommand.SetSpeechRules setSpeech: + setSpeech.Result.TrySetException(exception); + break; } } @@ -1092,6 +1131,7 @@ internal sealed class SchoolWorker NativeLanguage = _nativeLanguage, Presence = school.CapturePresence(), DressRules = school.DressRules, + SpeechRules = school.SpeechRules, Owner = _owner, PortraitSettings = _portraitSettings, }); diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 3ae06d7..4d39b3d 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -71,4 +71,11 @@ internal abstract record WorkerCommand DressRulePair? PendingStudents, DressRulePair? PendingStaff, TaskCompletionSource Result) : WorkerCommand; + + internal sealed record GetSpeechRules(TaskCompletionSource Result) : WorkerCommand; + + internal sealed record SetSpeechRules( + string? PendingStudents, + string? PendingStaff, + TaskCompletionSource Result) : WorkerCommand; } diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 94c09a6..11d1552 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -57,6 +57,7 @@ "opinionSiblingStart": 45, "opinionPartnerStart": 65, "opinionDriftPerMorning": 3, + "homeTalkOpinionScale": 0.5, "opinionTopCount": 5, "opinionBands": [ { "min": 70, "id": "OpinionCloseFriend" }, diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 69c2463..902e086 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -201,6 +201,7 @@ "ActionStarted": "started: {0}", "ActionEnded": "finished: {0}", "TalkEnded": "talked about {0}", + "HomeTalk": "at home this morning: {0}", "Whispered": "whispered", "TeacherInterrupted": "the teacher cut them off", "Quarreled": "quarreled", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 08a7891..99a16de 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -201,6 +201,7 @@ "ActionStarted": "начал: {0}", "ActionEnded": "закончил: {0}", "TalkEnded": "говорил о {0}", + "HomeTalk": "утром дома: {0}", "Whispered": "шептались", "TeacherInterrupted": "учитель оборвал", "Quarreled": "ссорились", diff --git a/src/HSchool.Simulation/MorningDress.cs b/src/HSchool.Simulation/MorningDress.cs index e4d9e51..cb6c55d 100644 --- a/src/HSchool.Simulation/MorningDress.cs +++ b/src/HSchool.Simulation/MorningDress.cs @@ -26,6 +26,12 @@ internal static class MorningDress changed = true; } + if (isWorkday && school.SpeechRules.HasPending) + { + school.SpeechRules = school.SpeechRules.ApplyPending(); + changed = true; + } + var offCampus = OffCampusIds(school); foreach (var person in school.Roster.People) { diff --git a/src/HSchool.Simulation/MorningOpinions.cs b/src/HSchool.Simulation/MorningOpinions.cs index d216043..78ccd0d 100644 --- a/src/HSchool.Simulation/MorningOpinions.cs +++ b/src/HSchool.Simulation/MorningOpinions.cs @@ -1,3 +1,4 @@ +using HSchool.Ai; using HSchool.Content; using HSchool.People; @@ -25,6 +26,11 @@ internal static class MorningOpinions changed |= DriftPerson(school.Roster, rules, peopleById, person, rules.OpinionDriftPerMorning); } + foreach (var person in school.Roster.People) + { + changed |= FamilyMorning(school, peopleById, person, rules); + } + return changed; } @@ -63,4 +69,87 @@ internal static class MorningOpinions return changed; } + + /// + /// One family topic per person. Parents stay off the map; this only moves the stored opinion. + /// Runs after drift so a parent already at the family basis still visibly shifts. + /// + private static bool FamilyMorning( + School school, + IReadOnlyDictionary peopleById, + Person person, + BehaviorDef rules) + { + var catalog = school.Catalog!; + var targetId = PickHomePartner(school.Roster!, person, peopleById); + if (targetId is null) + { + return false; + } + + var topicId = TalkCircles.PickTaggedTopic( + catalog, + TopicTags.Family, + Seed.Mix( + school.PeopleSeed, + person.Id, + DateOnly.FromDateTime(school.Clock.Time).DayNumber, + Seed.HomeSalt)); + if (topicId is null || !catalog.Topics.TryGetValue(topicId, out var topic)) + { + return false; + } + + var raw = topic.OpinionShift * rules.HomeTalkOpinionScale; + var delta = (int)Math.Round(raw, MidpointRounding.AwayFromZero); + if (delta == 0 && topic.OpinionShift != 0) + { + delta = topic.OpinionShift > 0 ? 1 : -1; + } + + if (delta == 0) + { + return false; + } + + var current = OpinionStore.Get(person, targetId) ?? 0; + var changed = OpinionStore.Set(person, targetId, current + delta); + school.AppendDayLog(new PersonLogEvent( + person.Id, + school.Clock.Time, + PersonLogTypes.HomeTalk, + topicId)); + return changed; + } + + private static string? PickHomePartner( + Roster roster, + Person person, + IReadOnlyDictionary peopleById) + { + var family = roster.Families.FirstOrDefault(candidate => + candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return null; + } + + foreach (var id in family.ParentIds.OrderBy(value => value, StringComparer.Ordinal)) + { + if (!id.Equals(person.Id, StringComparison.Ordinal) && peopleById.ContainsKey(id)) + { + return id; + } + } + + foreach (var id in family.ChildIds.OrderBy(value => value, StringComparer.Ordinal)) + { + if (!id.Equals(person.Id, StringComparison.Ordinal) && peopleById.ContainsKey(id)) + { + return id; + } + } + + return null; + } } diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs index 852b524..788e9e5 100644 --- a/src/HSchool.Simulation/PersonLog.cs +++ b/src/HSchool.Simulation/PersonLog.cs @@ -13,6 +13,7 @@ public static class PersonLogTypes public const string ActionStarted = "action-started"; public const string ActionEnded = "action-ended"; public const string TalkEnded = "talk-ended"; + public const string HomeTalk = "home-talk"; public const string Whispered = "whispered"; public const string TeacherInterrupted = "teacher-interrupted"; public const string Quarreled = "quarreled"; @@ -114,12 +115,14 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name); } - if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal)) + if (Type.Equals(PersonLogTypes.TalkEnded, StringComparison.Ordinal) + || Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal)) { var topic = catalog.Topics.TryGetValue(ThingDef ?? "", out var def) ? catalog.Label(locale, def) : ThingDef ?? Type; - return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "TalkEnded"), topic); + var key = Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal) ? "HomeTalk" : "TalkEnded"; + return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), topic); } var localeKey = TypeToLocaleKey(Type); diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 7d43461..ea94678 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -89,6 +89,9 @@ public sealed class School : IDisposable /// Student and staff dress rules. Pending pair applies on the next work morning. public SchoolDressRules DressRules { get; set; } = new(); + /// Student and staff speech-topic rules. Pending ids apply on the next work morning. + public SchoolSpeechRules SpeechRules { get; set; } = new(); + /// Skill everyone generated for this school speaks natively. public string? NativeLanguage { get; private set; } diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index ef73e1d..588cfda 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -11,7 +11,7 @@ internal sealed class ActiveTalkCircle public required string ActionId { get; init; } - public required string TopicId { get; init; } + public required string TopicId { get; set; } public required string NodeId { get; init; } @@ -227,8 +227,12 @@ internal static class TalkCircleSystem school.Catalog!, initiator, initiator.AgeOn(school.Clock.Time), - Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20)) - ?? school.Catalog!.Topics.Values.First(topic => !topic.Abstract).DefName; + Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20), + speechPolicy: school.SpeechRules.For(initiator.IsStudent)); + if (topicId is null) + { + return false; + } var circle = new ActiveTalkCircle { @@ -272,7 +276,8 @@ internal static class TalkCircleSystem initiator, initiator.AgeOn(school.Clock.Time), Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21), - whisperOnLesson: TalkActions.IsWhisper(action.DefName)); + whisperOnLesson: TalkActions.IsWhisper(action.DefName), + speechPolicy: school.SpeechRules.For(initiator.IsStudent)); if (TalkActions.IsTeacherTalk(action.DefName)) { topicId = TalkCircles.PickTeacherTopic( @@ -281,7 +286,10 @@ internal static class TalkCircleSystem Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.WhisperSalt)); } - topicId ??= school.Catalog.Topics.Values.First(topic => !topic.Abstract).DefName; + if (topicId is null) + { + return false; + } var circle = new ActiveTalkCircle { diff --git a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs index ea88aef..a56f135 100644 --- a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs +++ b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs @@ -113,6 +113,53 @@ public class TalkCirclesTests Assert.True(high > low); } + [Fact] + public void StudyOnlyPolicy_NeverPicksNonStudyTopic() + { + var (catalog, _, _) = World(); + var picker = Person("a", ["RussianLanguage"]); + for (var i = 0; i < 80; i++) + { + var id = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i * 137, speechPolicy: SpeechPolicies.StudyOnly); + Assert.NotNull(id); + Assert.Contains(TopicTags.Study, catalog.Topics[id].Tags, StringComparer.Ordinal); + } + } + + [Fact] + public void NoRudePolicy_NeverPicksRudeTopic() + { + var (catalog, _, _) = World(); + var picker = Person("a", ["RussianLanguage"]); + var rudeUnderFree = 0; + for (var i = 0; i < 11_000; i++) + { + var freeId = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i, speechPolicy: SpeechPolicies.Free); + if (freeId is not null && catalog.Topics[freeId].Tags.Contains(TopicTags.Rude, StringComparer.Ordinal)) + { + rudeUnderFree++; + } + + var filtered = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i, speechPolicy: SpeechPolicies.NoRude); + Assert.NotNull(filtered); + Assert.DoesNotContain(TopicTags.Rude, catalog.Topics[filtered].Tags, StringComparer.Ordinal); + } + + Assert.True(rudeUnderFree > 0); + } + + [Fact] + public void FamilyTaggedTopic_IsFromTopicDef() + { + var (catalog, _, _) = World(); + var id = TalkCircles.PickTaggedTopic(catalog, TopicTags.Family, seed: 1); + Assert.NotNull(id); + Assert.Contains(TopicTags.Family, catalog.Topics[id].Tags, StringComparer.Ordinal); + var schoolShift = catalog.Topics[id].OpinionShift; + var home = (int)Math.Round(schoolShift * catalog.BehaviorRules!.HomeTalkOpinionScale, MidpointRounding.AwayFromZero); + Assert.True(Math.Abs(home) < Math.Abs(schoolShift) || schoolShift == 0); + } + [Fact] public void Enemies_AreNotRankedAsInvitees() { diff --git a/tests/HSchool.AppHost.Tests/SpeechRulesApiTests.cs b/tests/HSchool.AppHost.Tests/SpeechRulesApiTests.cs new file mode 100644 index 0000000..7675f5d --- /dev/null +++ b/tests/HSchool.AppHost.Tests/SpeechRulesApiTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class SpeechRulesApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task NewSchool_DefaultSpeech_IsFree() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Речь дефолт", Start, seed: 46); + + var response = await client.GetAsync($"/api/schools/{school.Id}/speech-rules", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rules = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(rules); + Assert.Equal("free", rules.Students); + Assert.Equal("free", rules.Staff); + Assert.Null(rules.PendingStudents); + Assert.Null(rules.PendingStaff); + } + + [Fact] + public async Task PostStudyOnlyToday_QueuesPending_LeavesLiveFree() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Речь завтра", Start, seed: 46); + + var post = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/speech-rules", + new { students = "studyOnly" }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, post.StatusCode); + var queued = await post.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(queued); + Assert.Equal("free", queued.Students); + Assert.Equal("studyOnly", queued.PendingStudents); + + var get = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/speech-rules", + TestContext.Current.CancellationToken); + Assert.NotNull(get); + Assert.Equal("free", get.Students); + Assert.Equal("studyOnly", get.PendingStudents); + } + + [Fact] + public async Task UnknownPolicy_Returns400() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Плохая речь", Start, seed: 46); + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/speech-rules", + new { students = "silence" }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("unknown-speech", await SchoolApiTests.ProblemCodeAsync(response)); + } + + private sealed record SpeechRulesDto( + string Students, + string Staff, + string? PendingStudents, + string? PendingStaff); +} diff --git a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs index e306204..57dcae9 100644 --- a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs +++ b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs @@ -63,6 +63,47 @@ public class MorningOpinionsTests Assert.True(after < before); } + [Fact] + public void SkipFridayToMonday_AppliesHomeEventAndDrift() + { + var fridayEvening = new DateTime(2012, 4, 6, 22, 0, 0, DateTimeKind.Utc); + using var school = OpenEmpty(fridayEvening); + var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + var classmate = school.Roster.People.First(person => + person.IsStudent + && !person.Id.Equals(child.Id, StringComparison.Ordinal) + && !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id)); + var family = school.Roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal)); + var parentId = family.ParentIds + .OrderBy(id => id, StringComparer.Ordinal) + .First(id => school.Roster.People.Any(person => person.Id.Equals(id, StringComparison.Ordinal))); + var rules = school.Catalog!.BehaviorRules!; + var parentStart = rules.OpinionChildToParentStart; + + OpinionStore.Set(child, parentId, parentStart); + OpinionStore.Set(child, classmate.Id, 30); + + Assert.True(school.TrySkipEmpty().Succeeded); + Assert.Equal(new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc), school.Clock.Time); + + Assert.True(child.Opinions.TryGetValue(parentId, out var parentView)); + Assert.NotEqual(parentStart, parentView); + + Assert.True(child.Opinions.TryGetValue(classmate.Id, out var classmateView)); + Assert.True(classmateView < 30); + Assert.True(classmateView <= 30 - rules.OpinionDriftPerMorning); + + var schoolShift = school.Catalog.Topics.Values + .Where(topic => !topic.Abstract && topic.Tags.Contains(TopicTags.Family, StringComparer.Ordinal)) + .Select(topic => Math.Abs(topic.OpinionShift)) + .DefaultIfEmpty(2) + .Max(); + Assert.True(Math.Abs(parentView - parentStart) < schoolShift); + Assert.Contains( + school.DayLog, + row => row.PersonId == child.Id && row.Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal)); + } + private static void AdvanceTo(School school, DateTime until) { while (school.Clock.Time < until) diff --git a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs index 0d8729f..5396291 100644 --- a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs +++ b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs @@ -151,6 +151,75 @@ public class TalkCircleTests } } + [Fact] + public void PendingStudyOnlyToday_DoesNotChangeCurrentGamesCircle() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + OpinionStore.Set(first, second.Id, 55); + OpinionStore.Set(second, first.Id, 55); + PlaceAt(school, first.Id, "corridor-1"); + PlaceAt(school, second.Id, "corridor-1"); + Assert.True(school.TryStartAction(first.Id, TalkActions.Chat)); + var live = school.TalkCirclesById.Values.Single(); + live.TopicId = "TopicGames"; + + school.SpeechRules = school.SpeechRules.WithPending(SpeechPolicies.StudyOnly, null); + Assert.Equal(SpeechPolicies.Free, school.SpeechRules.Students); + + TalkCircleSystem.Apply(school, 0.5d); + Assert.Equal("TopicGames", school.TalkCircleOf(first.Id)?.TopicId); + Assert.Equal(SpeechPolicies.StudyOnly, school.SpeechRules.PendingStudents); + } + } + + [Fact] + public void AfterWorkMorning_NoRudePolicy_StudentsDoNotStartRudeTopic() + { + var (school, first, second) = TwoPupilsOnBreak(); + using (school) + { + school.SpeechRules = school.SpeechRules.WithPending(SpeechPolicies.NoRude, null); + MorningDress.Apply(school); + Assert.Equal(SpeechPolicies.NoRude, school.SpeechRules.Students); + Assert.Null(school.SpeechRules.PendingStudents); + + TalkCircleSystem.Interrupt(school, first.Id); + TalkCircleSystem.Interrupt(school, second.Id); + PlaceAt(school, first.Id, "corridor-1"); + PlaceAt(school, second.Id, "corridor-1"); + OpinionStore.Set(first, second.Id, 55); + OpinionStore.Set(second, first.Id, 55); + + var started = 0; + var classmates = school.Roster!.Classes.First(row => row.RoomId == "classroom-101").PupilIds + .Select(id => school.Roster.People.First(person => person.Id == id)) + .ToArray(); + foreach (var initiator in classmates) + { + TalkCircleSystem.Interrupt(school, initiator.Id); + PlaceAt(school, initiator.Id, "corridor-1"); + PlaceAt(school, first.Id, "corridor-1"); + PlaceAt(school, second.Id, "corridor-1"); + if (!school.TryStartAction(initiator.Id, TalkActions.Chat)) + { + continue; + } + + started++; + var circle = school.TalkCircleOf(initiator.Id); + Assert.NotNull(circle); + Assert.False( + school.Catalog!.Topics[circle.TopicId].Tags.Contains(TopicTags.Rude, StringComparer.Ordinal), + circle.TopicId); + TalkCircleSystem.Interrupt(school, initiator.Id); + } + + Assert.True(started > 0); + } + } + private static (School School, Person First, Person Second) TwoPupilsWithPhonesOnBreak() { var (catalog, map) = Vanilla();