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 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] В «Правилах» два комбобокса речи: ученики / сотрудники × свободно / без грубого / только учёба
|
- [x] В «Правилах» два комбобокса речи: ученики / сотрудники × свободно / без грубого / только учёба
|
||||||
- [ ] В силу с завтра; API сегодня не переодевает темы текущих кружков
|
- [x] В силу с завтра; API сегодня не переодевает темы текущих кружков
|
||||||
- [ ] Система не стартует кружок с темой вне политики; начатый вчера доигрывает
|
- [x] Система не стартует кружок с темой вне политики; начатый вчера доигрывает
|
||||||
- [ ] Одно семейное событие на человека за рабочее утро; skip ночи применяет
|
- [x] Одно семейное событие на человека за рабочее утро; skip ночи применяет
|
||||||
- [ ] Семейные темы из `TopicDef`; сдвиг меньше школьного разговора
|
- [x] Семейные темы из `TopicDef`; сдвиг меньше школьного разговора
|
||||||
- [ ] `docs/protocol.md` — HTTP правил речи
|
- [x] `docs/protocol.md` — HTTP правил речи
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] POST «только учёба» сегодня не мешает текущему кружку «игры»
|
- [x] POST «только учёба» сегодня не мешает текущему кружку «игры»
|
||||||
- [ ] На следующее рабочее утро грубая тема у учеников не стартует
|
- [x] На следующее рабочее утро грубая тема у учеников не стартует
|
||||||
- [ ] Skip с пятницы на понедельник применяет домашнее событие и дрейф мнений; мнение с
|
- [x] Skip с пятницы на понедельник применяет домашнее событие и дрейф мнений; мнение с
|
||||||
родителем сдвинуто, брошенная дружба ближе к нулю
|
родителем сдвинуто, брошенная дружба ближе к нулю
|
||||||
- [ ] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра»
|
- [x] Клиентский тест: комбобоксы речи рядом с одеждой, подпись «с завтра»
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -572,6 +572,36 @@ omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`.
|
|||||||
{ "students": { "form": "strict", "color": "noBright" } }
|
{ "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`
|
### `GET /api/schools/{id}/staffing`
|
||||||
|
|
||||||
Money, uncovered subjects, the applicant pool and current staff. Only the owner may read this
|
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,
|
Person picker,
|
||||||
int age,
|
int age,
|
||||||
int seed,
|
int seed,
|
||||||
bool whisperOnLesson = false)
|
bool whisperOnLesson = false,
|
||||||
|
string? speechPolicy = null)
|
||||||
{
|
{
|
||||||
var candidates = new List<(TopicDef Topic, float Weight)>();
|
var candidates = new List<(TopicDef Topic, float Weight)>();
|
||||||
foreach (var topic in catalog.Topics.Values)
|
foreach (var topic in catalog.Topics.Values)
|
||||||
@@ -208,7 +209,8 @@ public static class TalkCircles
|
|||||||
if (topic.Abstract
|
if (topic.Abstract
|
||||||
|| !RoleFitsTopic(topic, picker.IsStudent, picker.IsStaff, picker.IsParent)
|
|| !RoleFitsTopic(topic, picker.IsStudent, picker.IsStaff, picker.IsParent)
|
||||||
|| !AgeFits(topic, age)
|
|| !AgeFits(topic, age)
|
||||||
|| (whisperOnLesson && !topic.WhisperOnLesson))
|
|| (whisperOnLesson && !topic.WhisperOnLesson)
|
||||||
|
|| !SpeechPolicies.Allows(speechPolicy ?? SpeechPolicies.Free, topic))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -253,6 +255,21 @@ public static class TalkCircles
|
|||||||
return candidates[^1].Topic.DefName;
|
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>
|
/// <summary>Best shared language skill id among participants, or null.</summary>
|
||||||
public static string? SharedLanguage(
|
public static string? SharedLanguage(
|
||||||
DefCatalog catalog,
|
DefCatalog catalog,
|
||||||
|
|||||||
@@ -315,11 +315,17 @@ const ru = {
|
|||||||
rulesStudentsColor: 'Цвет учеников',
|
rulesStudentsColor: 'Цвет учеников',
|
||||||
rulesStaffForm: 'Форма сотрудников',
|
rulesStaffForm: 'Форма сотрудников',
|
||||||
rulesStaffColor: 'Цвет сотрудников',
|
rulesStaffColor: 'Цвет сотрудников',
|
||||||
rulesRoleSummary: '{role}: {form}, {color}',
|
rulesRoleSummary: '{role}: {form}, {color}; {speech}',
|
||||||
rulesApply: 'Применить',
|
rulesApply: 'Применить',
|
||||||
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
|
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
|
||||||
rulesSaveFailed: 'Не удалось сохранить правила.',
|
rulesSaveFailed: 'Не удалось сохранить правила.',
|
||||||
rulesPickHint: 'Правила действуют на всю школу.',
|
rulesPickHint: 'Правила действуют на всю школу.',
|
||||||
|
rulesStudentsSpeech: 'Речь учеников',
|
||||||
|
rulesStaffSpeech: 'Речь сотрудников',
|
||||||
|
speechPolicyFree: 'свободно',
|
||||||
|
speechPolicyNoRude: 'без грубого',
|
||||||
|
speechPolicyStudyOnly: 'только учёба',
|
||||||
|
speechErrorUnknownPolicy: 'Такой политики речи нет.',
|
||||||
dressFormRegular: 'обычная',
|
dressFormRegular: 'обычная',
|
||||||
dressFormShort: 'короткая',
|
dressFormShort: 'короткая',
|
||||||
dressFormStrict: 'строгая',
|
dressFormStrict: 'строгая',
|
||||||
@@ -688,11 +694,17 @@ const en: Messages = {
|
|||||||
rulesStudentsColor: 'Student colours',
|
rulesStudentsColor: 'Student colours',
|
||||||
rulesStaffForm: 'Staff form',
|
rulesStaffForm: 'Staff form',
|
||||||
rulesStaffColor: 'Staff colours',
|
rulesStaffColor: 'Staff colours',
|
||||||
rulesRoleSummary: '{role}: {form}, {color}',
|
rulesRoleSummary: '{role}: {form}, {color}; {speech}',
|
||||||
rulesApply: 'Apply',
|
rulesApply: 'Apply',
|
||||||
rulesLoadFailed: 'Could not load dress rules.',
|
rulesLoadFailed: 'Could not load dress rules.',
|
||||||
rulesSaveFailed: 'Could not save the rules.',
|
rulesSaveFailed: 'Could not save the rules.',
|
||||||
rulesPickHint: 'Rules apply to the whole school.',
|
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',
|
dressFormRegular: 'regular',
|
||||||
dressFormShort: 'short',
|
dressFormShort: 'short',
|
||||||
dressFormStrict: 'strict',
|
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 {
|
export interface TimetableLesson {
|
||||||
readonly classId: string;
|
readonly classId: string;
|
||||||
readonly classYear: number;
|
readonly classYear: number;
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
fetchDressRules,
|
fetchDressRules,
|
||||||
|
fetchSpeechRules,
|
||||||
setDressRules,
|
setDressRules,
|
||||||
|
setSpeechRules,
|
||||||
type DressRules,
|
type DressRules,
|
||||||
|
type SpeechRules,
|
||||||
} from '../net/api.ts';
|
} from '../net/api.ts';
|
||||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
@@ -17,7 +20,9 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
fetchDressRules: vi.fn(),
|
fetchDressRules: vi.fn(),
|
||||||
|
fetchSpeechRules: vi.fn(),
|
||||||
setDressRules: 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', () => {
|
describe('DressRulesPanel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setLocale('en');
|
setLocale('en');
|
||||||
vi.mocked(fetchDressRules).mockReset();
|
vi.mocked(fetchDressRules).mockReset();
|
||||||
|
vi.mocked(fetchSpeechRules).mockReset();
|
||||||
vi.mocked(setDressRules).mockReset();
|
vi.mocked(setDressRules).mockReset();
|
||||||
|
vi.mocked(setSpeechRules).mockReset();
|
||||||
vi.mocked(fetchDressRules).mockResolvedValue(rules());
|
vi.mocked(fetchDressRules).mockResolvedValue(rules());
|
||||||
|
vi.mocked(fetchSpeechRules).mockResolvedValue(speech());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -49,7 +66,7 @@ describe('DressRulesPanel', () => {
|
|||||||
const panel = new DressRulesPanel();
|
const panel = new DressRulesPanel();
|
||||||
document.body.append(panel.element);
|
document.body.append(panel.element);
|
||||||
panel.show(3);
|
panel.show(3);
|
||||||
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
|
||||||
|
|
||||||
const colorSelect = panel.element.querySelector('select.input');
|
const colorSelect = panel.element.querySelector('select.input');
|
||||||
if (!(colorSelect instanceof HTMLSelectElement)) {
|
if (!(colorSelect instanceof HTMLSelectElement)) {
|
||||||
@@ -66,7 +83,13 @@ describe('DressRulesPanel', () => {
|
|||||||
const panel = new DressRulesPanel();
|
const panel = new DressRulesPanel();
|
||||||
document.body.append(panel.element);
|
document.body.append(panel.element);
|
||||||
panel.show(3);
|
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');
|
const formSelect = panel.element.querySelector('fieldset select.input');
|
||||||
if (!(formSelect instanceof HTMLSelectElement)) {
|
if (!(formSelect instanceof HTMLSelectElement)) {
|
||||||
@@ -74,11 +97,6 @@ describe('DressRulesPanel', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formSelect.value = 'strict';
|
formSelect.value = 'strict';
|
||||||
const apply = panel.element.querySelector('button.button');
|
|
||||||
if (!(apply instanceof HTMLButtonElement)) {
|
|
||||||
throw new Error('apply button is missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
apply.click();
|
apply.click();
|
||||||
const banner = panel.element.querySelector('.staffing__error');
|
const banner = panel.element.querySelector('.staffing__error');
|
||||||
if (!(banner instanceof HTMLElement)) {
|
if (!(banner instanceof HTMLElement)) {
|
||||||
@@ -88,4 +106,40 @@ describe('DressRulesPanel', () => {
|
|||||||
await vi.waitFor(() => expect(banner.hidden).toBe(false));
|
await vi.waitFor(() => expect(banner.hidden).toBe(false));
|
||||||
expect(banner.textContent).toBe(t('dressErrorUnknownForm'));
|
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 { t } from '../i18n/strings.ts';
|
||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
dressFormLabel,
|
dressFormLabel,
|
||||||
dressFormOptions,
|
dressFormOptions,
|
||||||
dressRulesError,
|
dressRulesError,
|
||||||
|
speechPolicyLabel,
|
||||||
|
speechPolicyOptions,
|
||||||
} from './dressRulesUi.ts';
|
} from './dressRulesUi.ts';
|
||||||
import { fillSelect } from './staffingUi.ts';
|
import { fillSelect } from './staffingUi.ts';
|
||||||
|
|
||||||
@@ -26,10 +28,20 @@ export class DressRulesPanel {
|
|||||||
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
|
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
|
||||||
private readonly studentsColorLabel = el('span', { class: 'people__label' });
|
private readonly studentsColorLabel = el('span', { class: 'people__label' });
|
||||||
private readonly studentsColorSelect = el('select', { class: 'input people__input' });
|
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 staffFormLabel = el('span', { class: 'people__label' });
|
||||||
private readonly staffFormSelect = el('select', { class: 'input people__input' });
|
private readonly staffFormSelect = el('select', { class: 'input people__input' });
|
||||||
private readonly staffColorLabel = el('span', { class: 'people__label' });
|
private readonly staffColorLabel = el('span', { class: 'people__label' });
|
||||||
private readonly staffColorSelect = el('select', { class: 'input people__input' });
|
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 applyButton = el('button', { class: 'button', type: 'button' });
|
||||||
|
|
||||||
private readonly studentsLegend: HTMLElement;
|
private readonly studentsLegend: HTMLElement;
|
||||||
@@ -37,6 +49,7 @@ export class DressRulesPanel {
|
|||||||
|
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
private rules: DressRules | null = null;
|
private rules: DressRules | null = null;
|
||||||
|
private speech: SpeechRules | null = null;
|
||||||
private loadToken = 0;
|
private loadToken = 0;
|
||||||
private busy = false;
|
private busy = false;
|
||||||
|
|
||||||
@@ -70,6 +83,12 @@ export class DressRulesPanel {
|
|||||||
this.studentsColorLabel,
|
this.studentsColorLabel,
|
||||||
this.studentsColorSelect,
|
this.studentsColorSelect,
|
||||||
),
|
),
|
||||||
|
el(
|
||||||
|
'label',
|
||||||
|
{ class: 'people__field' },
|
||||||
|
this.studentsSpeechLabel,
|
||||||
|
this.studentsSpeechSelect,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
el(
|
el(
|
||||||
'fieldset',
|
'fieldset',
|
||||||
@@ -77,6 +96,7 @@ export class DressRulesPanel {
|
|||||||
el('legend', { class: 'people__section-title', text: '' }),
|
el('legend', { class: 'people__section-title', text: '' }),
|
||||||
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
|
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
|
||||||
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
|
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
|
||||||
|
el('label', { class: 'people__field' }, this.staffSpeechLabel, this.staffSpeechSelect),
|
||||||
),
|
),
|
||||||
this.applyButton,
|
this.applyButton,
|
||||||
),
|
),
|
||||||
@@ -100,15 +120,19 @@ export class DressRulesPanel {
|
|||||||
this.pendingHint.textContent = t('rulesPendingHint');
|
this.pendingHint.textContent = t('rulesPendingHint');
|
||||||
this.studentsFormLabel.textContent = t('rulesStudentsForm');
|
this.studentsFormLabel.textContent = t('rulesStudentsForm');
|
||||||
this.studentsColorLabel.textContent = t('rulesStudentsColor');
|
this.studentsColorLabel.textContent = t('rulesStudentsColor');
|
||||||
|
this.studentsSpeechLabel.textContent = t('rulesStudentsSpeech');
|
||||||
this.staffFormLabel.textContent = t('rulesStaffForm');
|
this.staffFormLabel.textContent = t('rulesStaffForm');
|
||||||
this.staffColorLabel.textContent = t('rulesStaffColor');
|
this.staffColorLabel.textContent = t('rulesStaffColor');
|
||||||
|
this.staffSpeechLabel.textContent = t('rulesStaffSpeech');
|
||||||
this.applyButton.textContent = t('rulesApply');
|
this.applyButton.textContent = t('rulesApply');
|
||||||
this.studentsLegend.textContent = t('rulesStudentsRole');
|
this.studentsLegend.textContent = t('rulesStudentsRole');
|
||||||
this.staffLegend.textContent = t('rulesStaffRole');
|
this.staffLegend.textContent = t('rulesStaffRole');
|
||||||
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
|
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
|
||||||
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
|
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
|
||||||
|
fillSelect(this.studentsSpeechSelect, speechPolicyOptions(), this.studentsSpeechSelect.value);
|
||||||
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
|
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
|
||||||
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
|
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
|
||||||
|
fillSelect(this.staffSpeechSelect, speechPolicyOptions(), this.staffSpeechSelect.value);
|
||||||
this.paint();
|
this.paint();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +140,7 @@ export class DressRulesPanel {
|
|||||||
if (this.schoolId !== schoolId) {
|
if (this.schoolId !== schoolId) {
|
||||||
this.schoolId = schoolId;
|
this.schoolId = schoolId;
|
||||||
this.rules = null;
|
this.rules = null;
|
||||||
|
this.speech = null;
|
||||||
this.clearError();
|
this.clearError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +163,13 @@ export class DressRulesPanel {
|
|||||||
|
|
||||||
const token = ++this.loadToken;
|
const token = ++this.loadToken;
|
||||||
try {
|
try {
|
||||||
const rules = await fetchDressRules(schoolId);
|
const [rules, speech] = await Promise.all([fetchDressRules(schoolId), fetchSpeechRules(schoolId)]);
|
||||||
if (token !== this.loadToken) {
|
if (token !== this.loadToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rules = rules;
|
this.rules = rules;
|
||||||
|
this.speech = speech;
|
||||||
this.paint();
|
this.paint();
|
||||||
} catch {
|
} catch {
|
||||||
if (token !== this.loadToken) {
|
if (token !== this.loadToken) {
|
||||||
@@ -156,9 +182,10 @@ export class DressRulesPanel {
|
|||||||
|
|
||||||
private paint(): void {
|
private paint(): void {
|
||||||
const rules = this.rules;
|
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.currentStudents.textContent = '—';
|
||||||
this.currentStaff.textContent = '—';
|
this.currentStaff.textContent = '—';
|
||||||
return;
|
return;
|
||||||
@@ -168,11 +195,13 @@ export class DressRulesPanel {
|
|||||||
role: t('rulesStudentsRole'),
|
role: t('rulesStudentsRole'),
|
||||||
form: dressFormLabel(rules.students.form),
|
form: dressFormLabel(rules.students.form),
|
||||||
color: dressColorLabel(rules.students.color),
|
color: dressColorLabel(rules.students.color),
|
||||||
|
speech: speechPolicyLabel(speech.students),
|
||||||
});
|
});
|
||||||
this.currentStaff.textContent = t('rulesRoleSummary', {
|
this.currentStaff.textContent = t('rulesRoleSummary', {
|
||||||
role: t('rulesStaffRole'),
|
role: t('rulesStaffRole'),
|
||||||
form: dressFormLabel(rules.staff.form),
|
form: dressFormLabel(rules.staff.form),
|
||||||
color: dressColorLabel(rules.staff.color),
|
color: dressColorLabel(rules.staff.color),
|
||||||
|
speech: speechPolicyLabel(speech.staff),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingStudents = rules.pendingStudents ?? rules.students;
|
const pendingStudents = rules.pendingStudents ?? rules.students;
|
||||||
@@ -181,12 +210,19 @@ export class DressRulesPanel {
|
|||||||
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
|
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
|
||||||
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
|
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
|
||||||
fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color);
|
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> {
|
private async apply(): Promise<void> {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
const rules = this.rules;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +237,21 @@ export class DressRulesPanel {
|
|||||||
body.staff = staff;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +259,14 @@ export class DressRulesPanel {
|
|||||||
this.clearError();
|
this.clearError();
|
||||||
this.paint();
|
this.paint();
|
||||||
try {
|
try {
|
||||||
this.rules = await setDressRules(schoolId, body);
|
if (body.students !== undefined || body.staff !== undefined) {
|
||||||
|
this.rules = await setDressRules(schoolId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speechBody.students !== undefined || speechBody.staff !== undefined) {
|
||||||
|
this.speech = await setSpeechRules(schoolId, speechBody);
|
||||||
|
}
|
||||||
|
|
||||||
this.paint();
|
this.paint();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(dressRulesError(error));
|
this.showError(dressRulesError(error));
|
||||||
|
|||||||
@@ -51,7 +51,30 @@ export function dressRulesError(error: unknown): string {
|
|||||||
return t('dressErrorUnknownForm');
|
return t('dressErrorUnknownForm');
|
||||||
case 'unknown-color':
|
case 'unknown-color':
|
||||||
return t('dressErrorUnknownColor');
|
return t('dressErrorUnknownColor');
|
||||||
|
case 'unknown-speech':
|
||||||
|
return t('speechErrorUnknownPolicy');
|
||||||
default:
|
default:
|
||||||
return error.message.length > 0 ? error.message : t('rulesSaveFailed');
|
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,
|
fetchPerson,
|
||||||
fetchGameStatus,
|
fetchGameStatus,
|
||||||
fetchDressRules,
|
fetchDressRules,
|
||||||
|
fetchSpeechRules,
|
||||||
fetchStaffing,
|
fetchStaffing,
|
||||||
fetchTimetable,
|
fetchTimetable,
|
||||||
hireStaff,
|
hireStaff,
|
||||||
@@ -27,6 +28,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
|||||||
fetchStaffing: vi.fn(),
|
fetchStaffing: vi.fn(),
|
||||||
fetchTimetable: vi.fn(),
|
fetchTimetable: vi.fn(),
|
||||||
fetchDressRules: vi.fn(),
|
fetchDressRules: vi.fn(),
|
||||||
|
fetchSpeechRules: vi.fn(),
|
||||||
fetchPerson: vi.fn(),
|
fetchPerson: vi.fn(),
|
||||||
fetchGameStatus: vi.fn().mockResolvedValue({
|
fetchGameStatus: vi.fn().mockResolvedValue({
|
||||||
tick: 0,
|
tick: 0,
|
||||||
@@ -224,6 +226,7 @@ describe('ManagementPanel uncovered', () => {
|
|||||||
vi.mocked(fetchStaffing).mockReset();
|
vi.mocked(fetchStaffing).mockReset();
|
||||||
vi.mocked(fetchTimetable).mockReset();
|
vi.mocked(fetchTimetable).mockReset();
|
||||||
vi.mocked(fetchDressRules).mockReset();
|
vi.mocked(fetchDressRules).mockReset();
|
||||||
|
vi.mocked(fetchSpeechRules).mockReset();
|
||||||
vi.mocked(fetchStaffing).mockResolvedValue({
|
vi.mocked(fetchStaffing).mockResolvedValue({
|
||||||
...staffing(),
|
...staffing(),
|
||||||
applicants: [],
|
applicants: [],
|
||||||
@@ -245,6 +248,12 @@ describe('ManagementPanel uncovered', () => {
|
|||||||
pendingStudents: null,
|
pendingStudents: null,
|
||||||
pendingStaff: null,
|
pendingStaff: null,
|
||||||
});
|
});
|
||||||
|
vi.mocked(fetchSpeechRules).mockResolvedValue({
|
||||||
|
students: 'free',
|
||||||
|
staff: 'free',
|
||||||
|
pendingStudents: null,
|
||||||
|
pendingStaff: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -271,6 +280,7 @@ describe('ManagementPanel rules tab', () => {
|
|||||||
vi.mocked(fetchStaffing).mockReset();
|
vi.mocked(fetchStaffing).mockReset();
|
||||||
vi.mocked(fetchTimetable).mockReset();
|
vi.mocked(fetchTimetable).mockReset();
|
||||||
vi.mocked(fetchDressRules).mockReset();
|
vi.mocked(fetchDressRules).mockReset();
|
||||||
|
vi.mocked(fetchSpeechRules).mockReset();
|
||||||
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
|
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
|
||||||
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||||
vi.mocked(fetchDressRules).mockResolvedValue({
|
vi.mocked(fetchDressRules).mockResolvedValue({
|
||||||
@@ -279,6 +289,12 @@ describe('ManagementPanel rules tab', () => {
|
|||||||
pendingStudents: null,
|
pendingStudents: null,
|
||||||
pendingStaff: null,
|
pendingStaff: null,
|
||||||
});
|
});
|
||||||
|
vi.mocked(fetchSpeechRules).mockResolvedValue({
|
||||||
|
students: 'free',
|
||||||
|
staff: 'free',
|
||||||
|
pendingStudents: null,
|
||||||
|
pendingStaff: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -472,6 +472,12 @@ public sealed class BehaviorDef : Def
|
|||||||
/// <summary>Points each work morning moves an opinion toward family basis or zero.</summary>
|
/// <summary>Points each work morning moves an opinion toward family basis or zero.</summary>
|
||||||
public int OpinionDriftPerMorning { get; init; } = 3;
|
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>
|
/// <summary>How many non-family friends or enemies the card lists at the top.</summary>
|
||||||
public int OpinionTopCount { get; init; } = 5;
|
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 WhisperSalt = 12;
|
||||||
public const int OrientationSalt = 13;
|
public const int OrientationSalt = 13;
|
||||||
public const int ConflictSalt = 14;
|
public const int ConflictSalt = 14;
|
||||||
|
public const int HomeSalt = 15;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <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);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
|
|||||||
@@ -494,6 +494,81 @@ internal static class SchoolEndpoints
|
|||||||
})
|
})
|
||||||
.WithName("SetSchoolDressRules");
|
.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", (
|
schools.MapGet("/{id:int}/staffing", (
|
||||||
int id,
|
int id,
|
||||||
string? lang,
|
string? lang,
|
||||||
@@ -937,6 +1012,27 @@ internal static class SchoolEndpoints
|
|||||||
"That colour policy is not recognized."),
|
"That colour policy is not recognized."),
|
||||||
_ => Problem(StatusCodes.Status400BadRequest, "invalid-query", "The dress rules request is not valid."),
|
_ => 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>
|
/// <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? PendingStudents,
|
||||||
DressRulePair? PendingStaff,
|
DressRulePair? PendingStaff,
|
||||||
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
|
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),
|
new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result),
|
||||||
setRules.Result);
|
setRules.Result);
|
||||||
break;
|
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)
|
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
|
||||||
{
|
{
|
||||||
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
||||||
@@ -700,6 +719,7 @@ internal sealed class GameLoopService(
|
|||||||
createSeed: null,
|
createSeed: null,
|
||||||
save.Presence,
|
save.Presence,
|
||||||
save.DressRules,
|
save.DressRules,
|
||||||
|
save.SpeechRules,
|
||||||
save.Owner,
|
save.Owner,
|
||||||
save.PortraitSettings);
|
save.PortraitSettings);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
@@ -758,6 +778,7 @@ internal sealed class GameLoopService(
|
|||||||
int? createSeed = null,
|
int? createSeed = null,
|
||||||
IReadOnlyList<PresenceSnapshot>? presence = null,
|
IReadOnlyList<PresenceSnapshot>? presence = null,
|
||||||
SchoolDressRules? dressRules = null,
|
SchoolDressRules? dressRules = null,
|
||||||
|
SchoolSpeechRules? speechRules = null,
|
||||||
string? owner = null,
|
string? owner = null,
|
||||||
SwarmUiConfigFile? portraitSettings = null) =>
|
SwarmUiConfigFile? portraitSettings = null) =>
|
||||||
new(
|
new(
|
||||||
@@ -775,6 +796,7 @@ internal sealed class GameLoopService(
|
|||||||
createSeed,
|
createSeed,
|
||||||
presence,
|
presence,
|
||||||
dressRules,
|
dressRules,
|
||||||
|
speechRules,
|
||||||
owner,
|
owner,
|
||||||
portraitSettings,
|
portraitSettings,
|
||||||
_options,
|
_options,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ internal sealed class SchoolSave
|
|||||||
|
|
||||||
public SchoolDressRules? DressRules { get; init; }
|
public SchoolDressRules? DressRules { get; init; }
|
||||||
|
|
||||||
|
public SchoolSpeechRules? SpeechRules { get; init; }
|
||||||
|
|
||||||
/// <summary>Normalized player name. Missing or blank means ownerless.</summary>
|
/// <summary>Normalized player name. Missing or blank means ownerless.</summary>
|
||||||
public string? Owner { get; init; }
|
public string? Owner { get; init; }
|
||||||
|
|
||||||
@@ -179,6 +181,7 @@ internal sealed class SchoolStore
|
|||||||
NativeLanguage = save.NativeLanguage,
|
NativeLanguage = save.NativeLanguage,
|
||||||
Presence = save.Presence,
|
Presence = save.Presence,
|
||||||
DressRules = save.DressRules,
|
DressRules = save.DressRules,
|
||||||
|
SpeechRules = save.SpeechRules,
|
||||||
Owner = save.Owner,
|
Owner = save.Owner,
|
||||||
PortraitSettings = save.PortraitSettings,
|
PortraitSettings = save.PortraitSettings,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly int? _createSeed;
|
private readonly int? _createSeed;
|
||||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||||
private readonly SchoolDressRules? _savedDressRules;
|
private readonly SchoolDressRules? _savedDressRules;
|
||||||
|
private readonly SchoolSpeechRules? _savedSpeechRules;
|
||||||
private readonly string? _owner;
|
private readonly string? _owner;
|
||||||
private readonly SwarmUiConfigFile? _portraitSettings;
|
private readonly SwarmUiConfigFile? _portraitSettings;
|
||||||
private readonly Action<int> _onFailed;
|
private readonly Action<int> _onFailed;
|
||||||
@@ -76,6 +77,7 @@ internal sealed class SchoolWorker
|
|||||||
int? createSeed,
|
int? createSeed,
|
||||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||||
SchoolDressRules? savedDressRules,
|
SchoolDressRules? savedDressRules,
|
||||||
|
SchoolSpeechRules? savedSpeechRules,
|
||||||
string? owner,
|
string? owner,
|
||||||
SwarmUiConfigFile? portraitSettings,
|
SwarmUiConfigFile? portraitSettings,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
@@ -100,6 +102,7 @@ internal sealed class SchoolWorker
|
|||||||
_createSeed = createSeed;
|
_createSeed = createSeed;
|
||||||
_savedPresence = savedPresence;
|
_savedPresence = savedPresence;
|
||||||
_savedDressRules = savedDressRules;
|
_savedDressRules = savedDressRules;
|
||||||
|
_savedSpeechRules = savedSpeechRules;
|
||||||
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
|
_owner = string.IsNullOrWhiteSpace(owner) ? null : owner;
|
||||||
_portraitSettings = portraitSettings;
|
_portraitSettings = portraitSettings;
|
||||||
_options = options;
|
_options = options;
|
||||||
@@ -261,6 +264,7 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
_school = school;
|
_school = school;
|
||||||
school.DressRules = _savedDressRules ?? new SchoolDressRules();
|
school.DressRules = _savedDressRules ?? new SchoolDressRules();
|
||||||
|
school.SpeechRules = _savedSpeechRules ?? new SchoolSpeechRules();
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
|
|
||||||
if (_isNew)
|
if (_isNew)
|
||||||
@@ -494,6 +498,29 @@ internal sealed class SchoolWorker
|
|||||||
dirty = true;
|
dirty = true;
|
||||||
break;
|
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)
|
catch (Exception ex)
|
||||||
@@ -553,6 +580,12 @@ internal sealed class SchoolWorker
|
|||||||
case WorkerCommand.SetDressRules setRules:
|
case WorkerCommand.SetDressRules setRules:
|
||||||
setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
|
setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
|
||||||
break;
|
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:
|
case WorkerCommand.SetDressRules setRules:
|
||||||
setRules.Result.TrySetException(exception);
|
setRules.Result.TrySetException(exception);
|
||||||
break;
|
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,
|
NativeLanguage = _nativeLanguage,
|
||||||
Presence = school.CapturePresence(),
|
Presence = school.CapturePresence(),
|
||||||
DressRules = school.DressRules,
|
DressRules = school.DressRules,
|
||||||
|
SpeechRules = school.SpeechRules,
|
||||||
Owner = _owner,
|
Owner = _owner,
|
||||||
PortraitSettings = _portraitSettings,
|
PortraitSettings = _portraitSettings,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,4 +71,11 @@ internal abstract record WorkerCommand
|
|||||||
DressRulePair? PendingStudents,
|
DressRulePair? PendingStudents,
|
||||||
DressRulePair? PendingStaff,
|
DressRulePair? PendingStaff,
|
||||||
TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
|
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,
|
"opinionSiblingStart": 45,
|
||||||
"opinionPartnerStart": 65,
|
"opinionPartnerStart": 65,
|
||||||
"opinionDriftPerMorning": 3,
|
"opinionDriftPerMorning": 3,
|
||||||
|
"homeTalkOpinionScale": 0.5,
|
||||||
"opinionTopCount": 5,
|
"opinionTopCount": 5,
|
||||||
"opinionBands": [
|
"opinionBands": [
|
||||||
{ "min": 70, "id": "OpinionCloseFriend" },
|
{ "min": 70, "id": "OpinionCloseFriend" },
|
||||||
|
|||||||
@@ -201,6 +201,7 @@
|
|||||||
"ActionStarted": "started: {0}",
|
"ActionStarted": "started: {0}",
|
||||||
"ActionEnded": "finished: {0}",
|
"ActionEnded": "finished: {0}",
|
||||||
"TalkEnded": "talked about {0}",
|
"TalkEnded": "talked about {0}",
|
||||||
|
"HomeTalk": "at home this morning: {0}",
|
||||||
"Whispered": "whispered",
|
"Whispered": "whispered",
|
||||||
"TeacherInterrupted": "the teacher cut them off",
|
"TeacherInterrupted": "the teacher cut them off",
|
||||||
"Quarreled": "quarreled",
|
"Quarreled": "quarreled",
|
||||||
|
|||||||
@@ -201,6 +201,7 @@
|
|||||||
"ActionStarted": "начал: {0}",
|
"ActionStarted": "начал: {0}",
|
||||||
"ActionEnded": "закончил: {0}",
|
"ActionEnded": "закончил: {0}",
|
||||||
"TalkEnded": "говорил о {0}",
|
"TalkEnded": "говорил о {0}",
|
||||||
|
"HomeTalk": "утром дома: {0}",
|
||||||
"Whispered": "шептались",
|
"Whispered": "шептались",
|
||||||
"TeacherInterrupted": "учитель оборвал",
|
"TeacherInterrupted": "учитель оборвал",
|
||||||
"Quarreled": "ссорились",
|
"Quarreled": "ссорились",
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ internal static class MorningDress
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWorkday && school.SpeechRules.HasPending)
|
||||||
|
{
|
||||||
|
school.SpeechRules = school.SpeechRules.ApplyPending();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
var offCampus = OffCampusIds(school);
|
var offCampus = OffCampusIds(school);
|
||||||
foreach (var person in school.Roster.People)
|
foreach (var person in school.Roster.People)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using HSchool.Ai;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ internal static class MorningOpinions
|
|||||||
changed |= DriftPerson(school.Roster, rules, peopleById, person, rules.OpinionDriftPerMorning);
|
changed |= DriftPerson(school.Roster, rules, peopleById, person, rules.OpinionDriftPerMorning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var person in school.Roster.People)
|
||||||
|
{
|
||||||
|
changed |= FamilyMorning(school, peopleById, person, rules);
|
||||||
|
}
|
||||||
|
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,4 +69,87 @@ internal static class MorningOpinions
|
|||||||
|
|
||||||
return changed;
|
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 ActionStarted = "action-started";
|
||||||
public const string ActionEnded = "action-ended";
|
public const string ActionEnded = "action-ended";
|
||||||
public const string TalkEnded = "talk-ended";
|
public const string TalkEnded = "talk-ended";
|
||||||
|
public const string HomeTalk = "home-talk";
|
||||||
public const string Whispered = "whispered";
|
public const string Whispered = "whispered";
|
||||||
public const string TeacherInterrupted = "teacher-interrupted";
|
public const string TeacherInterrupted = "teacher-interrupted";
|
||||||
public const string Quarreled = "quarreled";
|
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);
|
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)
|
var topic = catalog.Topics.TryGetValue(ThingDef ?? "", out var def)
|
||||||
? catalog.Label(locale, def)
|
? catalog.Label(locale, def)
|
||||||
: ThingDef ?? Type;
|
: 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);
|
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>
|
/// <summary>Student and staff dress rules. Pending pair applies on the next work morning.</summary>
|
||||||
public SchoolDressRules DressRules { get; set; } = new();
|
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>
|
/// <summary>Skill everyone generated for this school speaks natively.</summary>
|
||||||
public string? NativeLanguage { get; private set; }
|
public string? NativeLanguage { get; private set; }
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ internal sealed class ActiveTalkCircle
|
|||||||
|
|
||||||
public required string ActionId { get; init; }
|
public required string ActionId { get; init; }
|
||||||
|
|
||||||
public required string TopicId { get; init; }
|
public required string TopicId { get; set; }
|
||||||
|
|
||||||
public required string NodeId { get; init; }
|
public required string NodeId { get; init; }
|
||||||
|
|
||||||
@@ -227,8 +227,12 @@ internal static class TalkCircleSystem
|
|||||||
school.Catalog!,
|
school.Catalog!,
|
||||||
initiator,
|
initiator,
|
||||||
initiator.AgeOn(school.Clock.Time),
|
initiator.AgeOn(school.Clock.Time),
|
||||||
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20))
|
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 20),
|
||||||
?? school.Catalog!.Topics.Values.First(topic => !topic.Abstract).DefName;
|
speechPolicy: school.SpeechRules.For(initiator.IsStudent));
|
||||||
|
if (topicId is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var circle = new ActiveTalkCircle
|
var circle = new ActiveTalkCircle
|
||||||
{
|
{
|
||||||
@@ -272,7 +276,8 @@ internal static class TalkCircleSystem
|
|||||||
initiator,
|
initiator,
|
||||||
initiator.AgeOn(school.Clock.Time),
|
initiator.AgeOn(school.Clock.Time),
|
||||||
Seed.Mix(school.PeopleSeed, initiator.Id, DateOnly.FromDateTime(school.Clock.Time).DayNumber, Seed.ApparelSalt + 21),
|
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))
|
if (TalkActions.IsTeacherTalk(action.DefName))
|
||||||
{
|
{
|
||||||
topicId = TalkCircles.PickTeacherTopic(
|
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));
|
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
|
var circle = new ActiveTalkCircle
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,6 +113,53 @@ public class TalkCirclesTests
|
|||||||
Assert.True(high > low);
|
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]
|
[Fact]
|
||||||
public void Enemies_AreNotRankedAsInvitees()
|
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);
|
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)
|
private static void AdvanceTo(School school, DateTime until)
|
||||||
{
|
{
|
||||||
while (school.Clock.Time < 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()
|
private static (School School, Person First, Person Second) TwoPupilsWithPhonesOnBreak()
|
||||||
{
|
{
|
||||||
var (catalog, map) = Vanilla();
|
var (catalog, map) = Vanilla();
|
||||||
|
|||||||
Reference in New Issue
Block a user