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/phases/09-social/README.md b/docs/phases/09-social/README.md
index d930df7..a15e24c 100644
--- a/docs/phases/09-social/README.md
+++ b/docs/phases/09-social/README.md
@@ -35,7 +35,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
-| [46. Политика речи и дом](46-speech-home.md) | 🔄 | Комбобоксы речи с завтра; утреннее семейное событие |
+| [46. Политика речи и дом](46-speech-home.md) | ✅ | Комбобоксы речи с завтра; утреннее семейное событие |
| [47. Пак romance](47-romance-pack.md) | ✅ | Ориентация, симпатия, пара 18+, патч тем |
46 стоит на 37 и 42; 47 — на 42 и 23. `example` не трогать.
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 b30d484..fe907e0 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.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs
index 4fb1c8c..86387c7 100644
--- a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs
@@ -125,6 +125,29 @@ internal sealed partial 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)
@@ -184,6 +207,12 @@ internal sealed partial 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;
}
}
@@ -221,6 +250,12 @@ internal sealed partial 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;
}
}
diff --git a/src/HSchool.Server/Game/SchoolWorker.Persist.cs b/src/HSchool.Server/Game/SchoolWorker.Persist.cs
index f5ba79f..395bf65 100644
--- a/src/HSchool.Server/Game/SchoolWorker.Persist.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.Persist.cs
@@ -207,6 +207,7 @@ internal sealed partial class SchoolWorker
NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(),
DressRules = school.DressRules,
+ SpeechRules = school.SpeechRules,
Owner = _owner,
PortraitSettings = _portraitSettings,
});
diff --git a/src/HSchool.Server/Game/SchoolWorker.Tick.cs b/src/HSchool.Server/Game/SchoolWorker.Tick.cs
index c0f6ef6..ff76730 100644
--- a/src/HSchool.Server/Game/SchoolWorker.Tick.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.Tick.cs
@@ -94,6 +94,7 @@ internal sealed partial class SchoolWorker
_school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
+ school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
PublishSnapshot();
if (_isNew)
diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs
index ca4fc6b..f547461 100644
--- a/src/HSchool.Server/Game/SchoolWorker.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.cs
@@ -35,6 +35,7 @@ internal sealed partial 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;
@@ -73,6 +74,7 @@ internal sealed partial class SchoolWorker
int? createSeed,
IReadOnlyList? savedPresence,
SchoolDressRules? savedDressRules,
+ SchoolSpeechRules? savedSpeechRules,
string? owner,
SwarmUiConfigFile? portraitSettings,
SimulationOptions options,
@@ -97,6 +99,7 @@ internal sealed partial class SchoolWorker
_createSeed = createSeed;
_savedPresence = savedPresence;
_savedDressRules = savedDressRules;
+ _savedSpeechRules = savedSpeechRules;
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
_portraitSettings = portraitSettings;
_options = options;
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 16d3e40..4bb2e41 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 56328e3..523a799 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 dd7a57c..313d538 100644
--- a/tests/HSchool.Ai.Tests/TalkCirclesTests.cs
+++ b/tests/HSchool.Ai.Tests/TalkCirclesTests.cs
@@ -219,6 +219,53 @@ public class TalkCirclesTests
Assert.Equal(50f + rules.TalkSkillPerHour, afterTalkHour);
}
+ [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 5847f7f..cab427c 100644
--- a/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs
+++ b/tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs
@@ -79,6 +79,47 @@ public class MorningOpinionsTests
Assert.False(child.Opinions.ContainsKey(classmate.Id));
}
+ [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 ea073ad..4a94695 100644
--- a/tests/HSchool.Simulation.Tests/TalkCircleTests.cs
+++ b/tests/HSchool.Simulation.Tests/TalkCircleTests.cs
@@ -173,6 +173,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();