Merge branch 'phase/46-speech-home'

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/phases/09-social/README.md
#	src/HSchool.Server/Game/SchoolWorker.cs
#	tests/HSchool.Ai.Tests/TalkCirclesTests.cs
#	tests/HSchool.Simulation.Tests/MorningOpinionsTests.cs
#	tests/HSchool.Simulation.Tests/TalkCircleTests.cs
This commit is contained in:
Leonid Pershin
2026-08-20 14:06:29 +03:00
35 changed files with 910 additions and 36 deletions
+10 -10
View File
@@ -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] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра»
## Критерий готовности
+1 -1
View File
@@ -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` не трогать.
+30
View File
@@ -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
+19 -2
View File
@@ -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;
}
/// <summary>Family-morning topics: tag only, no school role or speech-policy filter.</summary>
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;
}
/// <summary>Best shared language skill id among participants, or null.</summary>
public static string? SharedLanguage(
DefCatalog catalog,
+14 -2
View File
@@ -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',
+22
View File
@@ -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<SpeechRules> {
return request<SpeechRules>(`/api/schools/${schoolId}/speech-rules`);
}
export async function setSpeechRules(
schoolId: number,
body: { readonly students?: string; readonly staff?: string },
): Promise<SpeechRules> {
return request<SpeechRules>(`/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;
@@ -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);
});
});
+63 -6
View File
@@ -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<void> {
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 {
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));
+23
View File
@@ -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;
}
}
@@ -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(() => {
+6
View File
@@ -472,6 +472,12 @@ public sealed class BehaviorDef : Def
/// <summary>Points each work morning moves an opinion toward family basis or zero.</summary>
public int OpinionDriftPerMorning { get; init; } = 3;
/// <summary>
/// Family-morning opinion uses this fraction of the topic's school-talk shift. Below 1 so
/// breakfast is quieter than a corridor circle.
/// </summary>
public float HomeTalkOpinionScale { get; init; } = 0.5f;
/// <summary>How many non-family friends or enemies the card lists at the top.</summary>
public int OpinionTopCount { get; init; } = 5;
+68
View File
@@ -0,0 +1,68 @@
namespace HSchool.Content;
/// <summary>School speech-topic strictness. Values are wire and save ids.</summary>
public static class SpeechPolicies
{
public const string Free = "free";
public const string NoRude = "noRude";
public const string StudyOnly = "studyOnly";
public static readonly IReadOnlyList<string> All = [Free, NoRude, StudyOnly];
public static bool IsKnown(string value) =>
All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
/// <summary>
/// Whether this topic may start under the live policy. Already-running circles are not filtered.
/// </summary>
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;
}
}
/// <summary>Live speech rules plus optional next-day overrides waiting for a work morning.</summary>
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;
}
+1
View File
@@ -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;
/// <summary>A stream that belongs to the school rather than to one family.</summary>
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
+96
View File
@@ -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<SpeechRulesOutcome>());
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<SpeechRulesOutcome>());
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."),
};
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
@@ -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;
}
}
+10
View File
@@ -113,4 +113,14 @@ internal abstract record GameCommand
DressRulePair? PendingStudents,
DressRulePair? PendingStaff,
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
internal sealed record GetSpeechRules(
int SchoolId,
TaskCompletionSource<SpeechRulesOutcome> Result) : GameCommand;
internal sealed record SetSpeechRules(
int SchoolId,
string? PendingStudents,
string? PendingStaff,
TaskCompletionSource<SpeechRulesOutcome> Result) : GameCommand;
}
@@ -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<SpeechRulesOutcome> 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<StaffingOutcome> 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<PresenceSnapshot>? 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,
+3
View File
@@ -36,6 +36,8 @@ internal sealed class SchoolSave
public SchoolDressRules? DressRules { get; init; }
public SchoolSpeechRules? SpeechRules { get; init; }
/// <summary>Normalized player name. Missing or blank means ownerless.</summary>
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,
});
@@ -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;
}
}
@@ -207,6 +207,7 @@ internal sealed partial class SchoolWorker
NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(),
DressRules = school.DressRules,
SpeechRules = school.SpeechRules,
Owner = _owner,
PortraitSettings = _portraitSettings,
});
@@ -94,6 +94,7 @@ internal sealed partial class SchoolWorker
_school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
PublishSnapshot();
if (_isNew)
+3
View File
@@ -35,6 +35,7 @@ internal sealed partial class SchoolWorker
private readonly int? _createSeed;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly SchoolDressRules? _savedDressRules;
private readonly SchoolSpeechRules? _savedSpeechRules;
private readonly string? _owner;
private readonly SwarmUiConfigFile? _portraitSettings;
private readonly Action<int> _onFailed;
@@ -73,6 +74,7 @@ internal sealed partial class SchoolWorker
int? createSeed,
IReadOnlyList<PresenceSnapshot>? 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;
+7
View File
@@ -71,4 +71,11 @@ internal abstract record WorkerCommand
DressRulePair? PendingStudents,
DressRulePair? PendingStaff,
TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
internal sealed record GetSpeechRules(TaskCompletionSource<SpeechRulesOutcome> Result) : WorkerCommand;
internal sealed record SetSpeechRules(
string? PendingStudents,
string? PendingStaff,
TaskCompletionSource<SpeechRulesOutcome> Result) : WorkerCommand;
}
@@ -57,6 +57,7 @@
"opinionSiblingStart": 45,
"opinionPartnerStart": 65,
"opinionDriftPerMorning": 3,
"homeTalkOpinionScale": 0.5,
"opinionTopCount": 5,
"opinionBands": [
{ "min": 70, "id": "OpinionCloseFriend" },
@@ -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",
@@ -201,6 +201,7 @@
"ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}",
"TalkEnded": "говорил о {0}",
"HomeTalk": "утром дома: {0}",
"Whispered": "шептались",
"TeacherInterrupted": "учитель оборвал",
"Quarreled": "ссорились",
+6
View File
@@ -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)
{
+89
View File
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
private static bool FamilyMorning(
School school,
IReadOnlyDictionary<string, Person> 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<string, Person> 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;
}
}
+5 -2
View File
@@ -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);
+3
View File
@@ -89,6 +89,9 @@ public sealed class School : IDisposable
/// <summary>Student and staff dress rules. Pending pair applies on the next work morning.</summary>
public SchoolDressRules DressRules { get; set; } = new();
/// <summary>Student and staff speech-topic rules. Pending ids apply on the next work morning.</summary>
public SchoolSpeechRules SpeechRules { get; set; } = new();
/// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; }
+13 -5
View File
@@ -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
{
@@ -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()
{
@@ -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<SpeechRulesDto>(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<SpeechRulesDto>(TestContext.Current.CancellationToken);
Assert.NotNull(queued);
Assert.Equal("free", queued.Students);
Assert.Equal("studyOnly", queued.PendingStudents);
var get = await client.GetFromJsonAsync<SpeechRulesDto>(
$"/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);
}
@@ -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)
@@ -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();