Add speech-topic policy from tomorrow and a morning family talk.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра»
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ internal sealed 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;
|
||||
@@ -76,6 +77,7 @@ internal sealed class SchoolWorker
|
||||
int? createSeed,
|
||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||
SchoolDressRules? savedDressRules,
|
||||
SchoolSpeechRules? savedSpeechRules,
|
||||
string? owner,
|
||||
SwarmUiConfigFile? portraitSettings,
|
||||
SimulationOptions options,
|
||||
@@ -100,6 +102,7 @@ internal sealed class SchoolWorker
|
||||
_createSeed = createSeed;
|
||||
_savedPresence = savedPresence;
|
||||
_savedDressRules = savedDressRules;
|
||||
_savedSpeechRules = savedSpeechRules;
|
||||
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
|
||||
_portraitSettings = portraitSettings;
|
||||
_options = options;
|
||||
@@ -261,6 +264,7 @@ internal sealed class SchoolWorker
|
||||
|
||||
_school = school;
|
||||
school.DressRules = _savedDressRules ?? new SchoolDressRules();
|
||||
school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
|
||||
PublishSnapshot();
|
||||
|
||||
if (_isNew)
|
||||
@@ -494,6 +498,29 @@ internal sealed class SchoolWorker
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case WorkerCommand.GetSpeechRules getSpeech:
|
||||
getSpeech.Result.TrySetResult(SpeechRulesOutcome.Ok(school.SpeechRules));
|
||||
break;
|
||||
|
||||
case WorkerCommand.SetSpeechRules setSpeech:
|
||||
{
|
||||
var next = school.SpeechRules;
|
||||
if (setSpeech.PendingStudents is { } students)
|
||||
{
|
||||
next = next with { PendingStudents = students };
|
||||
}
|
||||
|
||||
if (setSpeech.PendingStaff is { } staff)
|
||||
{
|
||||
next = next with { PendingStaff = staff };
|
||||
}
|
||||
|
||||
school.SpeechRules = next;
|
||||
setSpeech.Result.TrySetResult(SpeechRulesOutcome.Ok(school.SpeechRules));
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -553,6 +580,12 @@ internal sealed class SchoolWorker
|
||||
case WorkerCommand.SetDressRules setRules:
|
||||
setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.GetSpeechRules getSpeech:
|
||||
getSpeech.Result.TrySetResult(SpeechRulesOutcome.Fail(SpeechRulesError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.SetSpeechRules setSpeech:
|
||||
setSpeech.Result.TrySetResult(SpeechRulesOutcome.Fail(SpeechRulesError.UnknownSchool));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,6 +623,12 @@ internal sealed class SchoolWorker
|
||||
case WorkerCommand.SetDressRules setRules:
|
||||
setRules.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.GetSpeechRules getSpeech:
|
||||
getSpeech.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.SetSpeechRules setSpeech:
|
||||
setSpeech.Result.TrySetException(exception);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,6 +1131,7 @@ internal sealed class SchoolWorker
|
||||
NativeLanguage = _nativeLanguage,
|
||||
Presence = school.CapturePresence(),
|
||||
DressRules = school.DressRules,
|
||||
SpeechRules = school.SpeechRules,
|
||||
Owner = _owner,
|
||||
PortraitSettings = _portraitSettings,
|
||||
});
|
||||
|
||||
@@ -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": "ссорились",
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -113,6 +113,53 @@ public class TalkCirclesTests
|
||||
Assert.True(high > low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StudyOnlyPolicy_NeverPicksNonStudyTopic()
|
||||
{
|
||||
var (catalog, _, _) = World();
|
||||
var picker = Person("a", ["RussianLanguage"]);
|
||||
for (var i = 0; i < 80; i++)
|
||||
{
|
||||
var id = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i * 137, speechPolicy: SpeechPolicies.StudyOnly);
|
||||
Assert.NotNull(id);
|
||||
Assert.Contains(TopicTags.Study, catalog.Topics[id].Tags, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoRudePolicy_NeverPicksRudeTopic()
|
||||
{
|
||||
var (catalog, _, _) = World();
|
||||
var picker = Person("a", ["RussianLanguage"]);
|
||||
var rudeUnderFree = 0;
|
||||
for (var i = 0; i < 11_000; i++)
|
||||
{
|
||||
var freeId = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i, speechPolicy: SpeechPolicies.Free);
|
||||
if (freeId is not null && catalog.Topics[freeId].Tags.Contains(TopicTags.Rude, StringComparer.Ordinal))
|
||||
{
|
||||
rudeUnderFree++;
|
||||
}
|
||||
|
||||
var filtered = TalkCircles.PickTopic(catalog, picker, age: 14, seed: i, speechPolicy: SpeechPolicies.NoRude);
|
||||
Assert.NotNull(filtered);
|
||||
Assert.DoesNotContain(TopicTags.Rude, catalog.Topics[filtered].Tags, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
Assert.True(rudeUnderFree > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FamilyTaggedTopic_IsFromTopicDef()
|
||||
{
|
||||
var (catalog, _, _) = World();
|
||||
var id = TalkCircles.PickTaggedTopic(catalog, TopicTags.Family, seed: 1);
|
||||
Assert.NotNull(id);
|
||||
Assert.Contains(TopicTags.Family, catalog.Topics[id].Tags, StringComparer.Ordinal);
|
||||
var schoolShift = catalog.Topics[id].OpinionShift;
|
||||
var home = (int)Math.Round(schoolShift * catalog.BehaviorRules!.HomeTalkOpinionScale, MidpointRounding.AwayFromZero);
|
||||
Assert.True(Math.Abs(home) < Math.Abs(schoolShift) || schoolShift == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enemies_AreNotRankedAsInvitees()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -63,6 +63,47 @@ public class MorningOpinionsTests
|
||||
Assert.True(after < before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipFridayToMonday_AppliesHomeEventAndDrift()
|
||||
{
|
||||
var fridayEvening = new DateTime(2012, 4, 6, 22, 0, 0, DateTimeKind.Utc);
|
||||
using var school = OpenEmpty(fridayEvening);
|
||||
var child = school.Roster!.People.First(person => person.IsStudent && !person.IsParent);
|
||||
var classmate = school.Roster.People.First(person =>
|
||||
person.IsStudent
|
||||
&& !person.Id.Equals(child.Id, StringComparison.Ordinal)
|
||||
&& !OpinionStore.FamilyMemberIds(school.Roster, child).Contains(person.Id));
|
||||
var family = school.Roster.Families.Single(row => row.Id.Equals(child.FamilyId, StringComparison.Ordinal));
|
||||
var parentId = family.ParentIds
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.First(id => school.Roster.People.Any(person => person.Id.Equals(id, StringComparison.Ordinal)));
|
||||
var rules = school.Catalog!.BehaviorRules!;
|
||||
var parentStart = rules.OpinionChildToParentStart;
|
||||
|
||||
OpinionStore.Set(child, parentId, parentStart);
|
||||
OpinionStore.Set(child, classmate.Id, 30);
|
||||
|
||||
Assert.True(school.TrySkipEmpty().Succeeded);
|
||||
Assert.Equal(new DateTime(2012, 4, 9, 6, 0, 0, DateTimeKind.Utc), school.Clock.Time);
|
||||
|
||||
Assert.True(child.Opinions.TryGetValue(parentId, out var parentView));
|
||||
Assert.NotEqual(parentStart, parentView);
|
||||
|
||||
Assert.True(child.Opinions.TryGetValue(classmate.Id, out var classmateView));
|
||||
Assert.True(classmateView < 30);
|
||||
Assert.True(classmateView <= 30 - rules.OpinionDriftPerMorning);
|
||||
|
||||
var schoolShift = school.Catalog.Topics.Values
|
||||
.Where(topic => !topic.Abstract && topic.Tags.Contains(TopicTags.Family, StringComparer.Ordinal))
|
||||
.Select(topic => Math.Abs(topic.OpinionShift))
|
||||
.DefaultIfEmpty(2)
|
||||
.Max();
|
||||
Assert.True(Math.Abs(parentView - parentStart) < schoolShift);
|
||||
Assert.Contains(
|
||||
school.DayLog,
|
||||
row => row.PersonId == child.Id && row.Type.Equals(PersonLogTypes.HomeTalk, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static void AdvanceTo(School school, DateTime until)
|
||||
{
|
||||
while (school.Clock.Time < until)
|
||||
|
||||
@@ -151,6 +151,75 @@ public class TalkCircleTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingStudyOnlyToday_DoesNotChangeCurrentGamesCircle()
|
||||
{
|
||||
var (school, first, second) = TwoPupilsOnBreak();
|
||||
using (school)
|
||||
{
|
||||
OpinionStore.Set(first, second.Id, 55);
|
||||
OpinionStore.Set(second, first.Id, 55);
|
||||
PlaceAt(school, first.Id, "corridor-1");
|
||||
PlaceAt(school, second.Id, "corridor-1");
|
||||
Assert.True(school.TryStartAction(first.Id, TalkActions.Chat));
|
||||
var live = school.TalkCirclesById.Values.Single();
|
||||
live.TopicId = "TopicGames";
|
||||
|
||||
school.SpeechRules = school.SpeechRules.WithPending(SpeechPolicies.StudyOnly, null);
|
||||
Assert.Equal(SpeechPolicies.Free, school.SpeechRules.Students);
|
||||
|
||||
TalkCircleSystem.Apply(school, 0.5d);
|
||||
Assert.Equal("TopicGames", school.TalkCircleOf(first.Id)?.TopicId);
|
||||
Assert.Equal(SpeechPolicies.StudyOnly, school.SpeechRules.PendingStudents);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterWorkMorning_NoRudePolicy_StudentsDoNotStartRudeTopic()
|
||||
{
|
||||
var (school, first, second) = TwoPupilsOnBreak();
|
||||
using (school)
|
||||
{
|
||||
school.SpeechRules = school.SpeechRules.WithPending(SpeechPolicies.NoRude, null);
|
||||
MorningDress.Apply(school);
|
||||
Assert.Equal(SpeechPolicies.NoRude, school.SpeechRules.Students);
|
||||
Assert.Null(school.SpeechRules.PendingStudents);
|
||||
|
||||
TalkCircleSystem.Interrupt(school, first.Id);
|
||||
TalkCircleSystem.Interrupt(school, second.Id);
|
||||
PlaceAt(school, first.Id, "corridor-1");
|
||||
PlaceAt(school, second.Id, "corridor-1");
|
||||
OpinionStore.Set(first, second.Id, 55);
|
||||
OpinionStore.Set(second, first.Id, 55);
|
||||
|
||||
var started = 0;
|
||||
var classmates = school.Roster!.Classes.First(row => row.RoomId == "classroom-101").PupilIds
|
||||
.Select(id => school.Roster.People.First(person => person.Id == id))
|
||||
.ToArray();
|
||||
foreach (var initiator in classmates)
|
||||
{
|
||||
TalkCircleSystem.Interrupt(school, initiator.Id);
|
||||
PlaceAt(school, initiator.Id, "corridor-1");
|
||||
PlaceAt(school, first.Id, "corridor-1");
|
||||
PlaceAt(school, second.Id, "corridor-1");
|
||||
if (!school.TryStartAction(initiator.Id, TalkActions.Chat))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
started++;
|
||||
var circle = school.TalkCircleOf(initiator.Id);
|
||||
Assert.NotNull(circle);
|
||||
Assert.False(
|
||||
school.Catalog!.Topics[circle.TopicId].Tags.Contains(TopicTags.Rude, StringComparer.Ordinal),
|
||||
circle.TopicId);
|
||||
TalkCircleSystem.Interrupt(school, initiator.Id);
|
||||
}
|
||||
|
||||
Assert.True(started > 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static (School School, Person First, Person Second) TwoPupilsWithPhonesOnBreak()
|
||||
{
|
||||
var (catalog, map) = Vanilla();
|
||||
|
||||
Reference in New Issue
Block a user