Add class-teacher slots on SchoolClass with owner-only assign/clear.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 09:37:37 +03:00
co-authored by Cursor
parent 99eca80d6d
commit 4096d5306e
22 changed files with 992 additions and 22 deletions
+18
View File
@@ -311,6 +311,15 @@ const ru = {
staffErrorNotTeacher: 'Предмет можно назначить только учителю.',
staffErrorAssigned: 'Этот предмет уже назначен.',
staffErrorSubject: 'Такого предмета нет.',
classTeachersTitle: 'Классные руководители',
classTeacherLabel: 'Классный',
classTeacherNone: 'Не назначен',
classTeacherClear: 'Снять',
classTeacherEmpty: 'Нет классов.',
classTeacherErrorNotTeacher: 'Классным можно назначить только нанятого учителя.',
classTeacherErrorUnknownClass: 'Такого класса нет.',
classTeacherErrorUnknownPerson: 'Этого человека нет в школе.',
classTeacherWithName: 'классный: {name}',
manageTabStaff: 'Штат',
manageTabRules: 'Правила',
@@ -705,6 +714,15 @@ const en: Messages = {
staffErrorNotTeacher: 'Only a teacher can be assigned a subject.',
staffErrorAssigned: 'That subject is already assigned.',
staffErrorSubject: 'That subject is not in the catalog.',
classTeachersTitle: 'Class teachers',
classTeacherLabel: 'Class teacher',
classTeacherNone: 'None',
classTeacherClear: 'Clear',
classTeacherEmpty: 'No classes.',
classTeacherErrorNotTeacher: 'Only a hired teacher can be the class teacher.',
classTeacherErrorUnknownClass: 'That class is not in this school.',
classTeacherErrorUnknownPerson: 'That person is not on the roster.',
classTeacherWithName: 'class teacher: {name}',
manageTabStaff: 'Staff',
manageTabRules: 'Rules',
+40
View File
@@ -327,6 +327,8 @@ export interface PersonCard {
readonly classYear: number | null;
readonly classLetter: string | null;
readonly classId: string | null;
readonly classTeacherId: string | null;
readonly classTeacherName: string | null;
readonly position: string | null;
readonly positionLabel: string | null;
readonly body: readonly LabeledStat[];
@@ -713,6 +715,14 @@ export interface StaffMember {
readonly subjects: readonly DefLabel[];
}
export interface ClassTeacherSlot {
readonly id: string;
readonly year: number;
readonly letter: string;
readonly classTeacherId: string | null;
readonly classTeacherName: string | null;
}
export interface Staffing {
readonly allocated: number;
readonly payroll: number;
@@ -722,6 +732,7 @@ export interface Staffing {
readonly staff: readonly StaffMember[];
readonly positions: readonly DefLabel[];
readonly subjects: readonly StaffingSubject[];
readonly classes: readonly ClassTeacherSlot[];
}
export async function fetchStaffing(schoolId: number, lang: string): Promise<Staffing> {
@@ -773,6 +784,35 @@ export async function unassignSubject(
);
}
export async function assignClassTeacher(
schoolId: number,
classId: string,
personId: string,
lang: string,
): Promise<Staffing> {
const params = new URLSearchParams({ lang });
return request<Staffing>(
`/api/schools/${schoolId}/classes/${encodeURIComponent(classId)}/class-teacher?${params.toString()}`,
{
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ personId }),
},
);
}
export async function clearClassTeacher(
schoolId: number,
classId: string,
lang: string,
): Promise<Staffing> {
const params = new URLSearchParams({ lang });
return request<Staffing>(
`/api/schools/${schoolId}/classes/${encodeURIComponent(classId)}/class-teacher?${params.toString()}`,
{ method: 'DELETE' },
);
}
export interface DressRulePair {
readonly form: string;
readonly color: string;
@@ -4,7 +4,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
assignClassTeacher,
assignSubject,
clearClassTeacher,
fetchPerson,
fetchGameStatus,
fetchDressRules,
@@ -45,6 +47,8 @@ vi.mock('../net/api.ts', async (importOriginal) => {
hireStaff: vi.fn(),
assignSubject: vi.fn(),
unassignSubject: vi.fn(),
assignClassTeacher: vi.fn(),
clearClassTeacher: vi.fn(),
};
});
@@ -64,6 +68,8 @@ function personCard(): PersonCard {
classYear: null,
classLetter: null,
classId: null,
classTeacherId: null,
classTeacherName: null,
position: null,
positionLabel: null,
body: [],
@@ -111,6 +117,7 @@ function staffing(): Staffing {
staff: [],
positions: [{ defName: 'Teacher', label: 'Teacher' }],
subjects: [],
classes: [],
};
}
@@ -392,6 +399,7 @@ function hiredTeacher(): Staffing {
teachersShort: 1,
},
],
classes: [{ id: 'c1', year: 5, letter: 'A', classTeacherId: null, classTeacherName: null }],
};
}
@@ -404,6 +412,8 @@ describe('ManagementPanel money and subjects', () => {
vi.mocked(fetchPerson).mockReset();
vi.mocked(assignSubject).mockReset();
vi.mocked(unassignSubject).mockReset();
vi.mocked(assignClassTeacher).mockReset();
vi.mocked(clearClassTeacher).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue(hiredTeacher());
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchPerson).mockResolvedValue({
@@ -505,4 +515,43 @@ describe('ManagementPanel money and subjects', () => {
);
await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(6_000)));
});
it('assigns and clears a class teacher from the class list', async () => {
const withTeacher: Staffing = {
...hiredTeacher(),
classes: [
{
id: 'c1',
year: 5,
letter: 'A',
classTeacherId: 't1',
classTeacherName: 'Petrov Ivan',
},
],
};
vi.mocked(assignClassTeacher).mockResolvedValue(withTeacher);
vi.mocked(clearClassTeacher).mockResolvedValue(hiredTeacher());
const panel = new ManagementPanel();
document.body.append(panel.listElement, panel.cardElement);
panel.show(2);
const select = await vi.waitFor(() => {
const node = panel.listElement.querySelector('.staffing__class-teachers select');
if (!(node instanceof HTMLSelectElement)) {
throw new Error('class teacher select is missing');
}
return node;
});
expect(panel.listElement.textContent).toContain(t('classTeachersTitle'));
select.value = 't1';
select.dispatchEvent(new Event('change'));
await vi.waitFor(() => expect(assignClassTeacher).toHaveBeenCalledWith(2, 'c1', 't1', 'en'));
await vi.waitFor(() => expect(select.value).toBe('t1'));
select.value = '';
select.dispatchEvent(new Event('change'));
await vi.waitFor(() => expect(clearClassTeacher).toHaveBeenCalledWith(2, 'c1', 'en'));
});
});
+90 -1
View File
@@ -1,5 +1,7 @@
import {
assignClassTeacher,
assignSubject,
clearClassTeacher,
fetchPerson,
fetchStaffing,
fetchTimetable,
@@ -16,11 +18,12 @@ import { DressRulesPanel } from './dressRulesPanel.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts';
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
import { actionError, classTeacherError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
import { loadChrome, patchChrome } from './viewState.ts';
const TEACHER = 'Teacher';
const CLEAR_VALUE = '';
/**
* Left: money, coverage, a door into the applicant window, staff. Right: the person card plus
@@ -45,6 +48,9 @@ export class ManagementPanel {
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
private readonly staffTable = el('table', { class: 'people__table' });
private readonly staffEmpty = el('p', { class: 'panel__empty' });
private readonly classTeachersTitle = el('h3', { class: 'panel__section-title' });
private readonly classTeachersList = el('div', { class: 'staffing__class-teachers' });
private readonly classTeachersEmpty = el('p', { class: 'panel__empty' });
private readonly timetableTitle = el('h3', { class: 'panel__section-title' });
private readonly classLabel = el('span', { class: 'people__label' });
private readonly classSelect = el('select', { class: 'input people__input' });
@@ -95,6 +101,13 @@ export class ManagementPanel {
this.staffTitle,
el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty),
),
el(
'div',
{ class: 'panel__section' },
this.classTeachersTitle,
this.classTeachersEmpty,
this.classTeachersList,
),
el(
'div',
{ class: 'panel__section' },
@@ -126,6 +139,8 @@ export class ManagementPanel {
this.applicantsTitle.textContent = t('staffApplicants');
this.applicantsButton.textContent = t('staffApplicantsOpen');
this.staffTitle.textContent = t('staffHired');
this.classTeachersTitle.textContent = t('classTeachersTitle');
this.classTeachersEmpty.textContent = t('classTeacherEmpty');
this.timetableTitle.textContent = t('timetableTitle');
this.classLabel.textContent = t('timetableClass');
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
@@ -283,6 +298,7 @@ export class ManagementPanel {
this.paintUncovered(staffing);
this.paintApplicants(staffing);
this.paintStaff(staffing);
this.paintClassTeachers(staffing);
this.paintClasses(this.timetable);
}
@@ -390,6 +406,79 @@ export class ManagementPanel {
}
}
private paintClassTeachers(staffing: Staffing | null): void {
const classes = staffing?.classes ?? [];
this.classTeachersEmpty.hidden = classes.length > 0;
this.classTeachersList.hidden = classes.length === 0;
clear(this.classTeachersList);
if (classes.length === 0) {
return;
}
const teachers = (staffing?.staff ?? []).filter((member) => member.position === TEACHER);
for (const schoolClass of classes) {
const select = el('select', {
class: 'input people__input',
disabled: this.busy,
}) as HTMLSelectElement;
const none = el('option', { text: t('classTeacherNone') });
none.value = CLEAR_VALUE;
select.append(none);
for (const teacher of teachers) {
const option = el('option', { text: teacher.fullName });
option.value = teacher.id;
select.append(option);
}
const current = schoolClass.classTeacherId ?? CLEAR_VALUE;
if (current !== CLEAR_VALUE && ![...select.options].some((option) => option.value === current)) {
const missing = el('option', {
text: schoolClass.classTeacherName ?? schoolClass.classTeacherId ?? current,
});
missing.value = current;
select.append(missing);
}
select.value = [...select.options].some((option) => option.value === current) ? current : CLEAR_VALUE;
select.addEventListener('change', () => {
void this.setClassTeacher(schoolClass.id, select.value === CLEAR_VALUE ? null : select.value);
});
this.classTeachersList.append(
el(
'label',
{ class: 'people__field staffing__class-teacher' },
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
select,
),
);
}
}
private async setClassTeacher(classId: string, personId: string | null): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null || this.busy) {
return;
}
this.busy = true;
this.clearError();
this.paint();
try {
this.staffing =
personId === null
? await clearClassTeacher(schoolId, classId, getLocale())
: await assignClassTeacher(schoolId, classId, personId, getLocale());
this.paint();
} catch (error) {
this.showError(classTeacherError(error));
this.paint();
} finally {
this.busy = false;
this.paint();
}
}
private async select(personId: string): Promise<void> {
this.selectedId = personId;
this.persist();
@@ -25,6 +25,8 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
classYear: 5,
classLetter: 'А',
classId: 'class-1',
classTeacherId: null,
classTeacherName: null,
position: null,
positionLabel: null,
body: [{ id: 'Height', label: 'Рост', value: '164' }],
@@ -120,6 +122,19 @@ function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement {
}
describe('renderPersonCard', () => {
it('shows the class teacher name next to the class', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(
root,
card({ classTeacherId: 't1', classTeacherName: 'Петров Иван' }),
() => {},
options(),
);
expect(root.textContent).toContain(t('classTeacherWithName', { name: 'Петров Иван' }));
});
it('switches overview, clothes, carried and now', () => {
setLocale('ru');
const root = document.createElement('div');
+11 -2
View File
@@ -56,10 +56,19 @@ export function roleLabels(roles: readonly string[]): string {
.join(', ');
}
export function placement(person: Pick<PersonListItem, 'classYear' | 'classLetter' | 'positionLabel'>): string {
export function placement(
person: Pick<PersonListItem, 'classYear' | 'classLetter' | 'positionLabel'> & {
readonly classTeacherName?: string | null;
},
): string {
const parts: string[] = [];
if (person.classYear !== null && person.classLetter !== null) {
parts.push(`${person.classYear}${person.classLetter}`);
let classBit = `${person.classYear}${person.classLetter}`;
if (person.classTeacherName !== null && person.classTeacherName !== undefined && person.classTeacherName.length > 0) {
classBit += ` · ${t('classTeacherWithName', { name: person.classTeacherName })}`;
}
parts.push(classBit);
}
if (person.positionLabel !== null && person.positionLabel.length > 0) {
+17
View File
@@ -71,6 +71,23 @@ export function actionError(error: unknown): string {
}
}
export function classTeacherError(error: unknown): string {
if (!(error instanceof ApiError)) {
return t('staffActionFailed');
}
switch (error.code) {
case 'not-teacher':
return t('classTeacherErrorNotTeacher');
case 'unknown-class':
return t('classTeacherErrorUnknownClass');
case 'unknown-person':
return t('classTeacherErrorUnknownPerson');
default:
return error.message.length > 0 ? error.message : t('staffActionFailed');
}
}
export function parseOptionalInt(value: string): number | undefined {
if (value === '') {
return undefined;
@@ -144,6 +144,8 @@ function card(overrides: Partial<PersonCard>): PersonCard {
classYear: 5,
classLetter: 'A',
classId: 'c1',
classTeacherId: null,
classTeacherName: null,
position: null,
positionLabel: null,
body: [],
+82
View File
@@ -0,0 +1,82 @@
namespace HSchool.People;
public enum ClassTeacherError
{
None,
UnknownSchool,
UnknownClass,
UnknownPerson,
NotTeacher,
}
public sealed record ClassTeacherOutcome(ClassTeacherError Error, Roster Roster);
/// <summary>
/// Assigns or clears the class-teacher slot on a <see cref="SchoolClass"/>. Not a
/// <see cref="PositionDef"/> — any hired teacher may cover several classes.
/// </summary>
public static class ClassTeachers
{
public static ClassTeacherOutcome UnknownSchool() =>
new(ClassTeacherError.UnknownSchool, new Roster([], [], []));
public static ClassTeacherOutcome Assign(Roster roster, string classId, string personId)
{
ArgumentNullException.ThrowIfNull(roster);
ArgumentNullException.ThrowIfNull(classId);
ArgumentNullException.ThrowIfNull(personId);
var index = IndexOf(roster.Classes, classId);
if (index < 0)
{
return new ClassTeacherOutcome(ClassTeacherError.UnknownClass, roster);
}
var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
if (person is null)
{
return new ClassTeacherOutcome(ClassTeacherError.UnknownPerson, roster);
}
if (!person.IsStaff || !string.Equals(person.Position, Staffing.TeacherPosition, StringComparison.Ordinal))
{
return new ClassTeacherOutcome(ClassTeacherError.NotTeacher, roster);
}
return Replace(roster, index, personId);
}
public static ClassTeacherOutcome Clear(Roster roster, string classId)
{
ArgumentNullException.ThrowIfNull(roster);
ArgumentNullException.ThrowIfNull(classId);
var index = IndexOf(roster.Classes, classId);
if (index < 0)
{
return new ClassTeacherOutcome(ClassTeacherError.UnknownClass, roster);
}
return Replace(roster, index, null);
}
private static ClassTeacherOutcome Replace(Roster roster, int index, string? teacherId)
{
var classes = roster.Classes.ToArray();
classes[index] = classes[index] with { ClassTeacherId = teacherId };
return new ClassTeacherOutcome(ClassTeacherError.None, roster with { Classes = classes });
}
private static int IndexOf(IReadOnlyList<SchoolClass> classes, string classId)
{
for (var i = 0; i < classes.Count; i++)
{
if (classes[i].Id.Equals(classId, StringComparison.Ordinal))
{
return i;
}
}
return -1;
}
}
+3 -1
View File
@@ -142,4 +142,6 @@ public sealed record SchoolClass(
string Letter,
string RoomId,
int Capacity,
IReadOnlyList<string> PupilIds);
IReadOnlyList<string> PupilIds,
/// <summary>Hired teacher who is the class teacher, or null when the slot is empty.</summary>
string? ClassTeacherId = null);
+46 -3
View File
@@ -85,7 +85,9 @@ internal sealed record PersonCardResponse(
PersonConnectionsResponse? Connections = null,
DefLabelResponse? Orientation = null,
IReadOnlyList<string>? TalkCircleMemberIds = null,
string? TalkTopicId = null);
string? TalkTopicId = null,
string? ClassTeacherId = null,
string? ClassTeacherName = null);
internal sealed record WornItemResponse(
@@ -246,6 +248,8 @@ internal sealed record HireStaffRequest(string? PersonId, string? Position);
internal sealed record AssignSubjectRequest(string? Subject);
internal sealed record AssignClassTeacherRequest(string? PersonId);
internal sealed record StaffingResponse(
float Allocated,
float Payroll,
@@ -254,7 +258,15 @@ internal sealed record StaffingResponse(
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff,
IReadOnlyList<DefLabelResponse> Positions,
IReadOnlyList<UncoveredSubjectResponse> Subjects);
IReadOnlyList<UncoveredSubjectResponse> Subjects,
IReadOnlyList<ClassTeacherSlotResponse> Classes);
internal sealed record ClassTeacherSlotResponse(
string Id,
int Year,
string Letter,
string? ClassTeacherId,
string? ClassTeacherName);
internal sealed record UncoveredSubjectResponse(
string DefName,
@@ -357,7 +369,38 @@ internal static class StaffingMapper
0))
.ToArray();
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
var peopleById = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var classes = roster.Classes
.OrderBy(schoolClass => schoolClass.Year)
.ThenBy(schoolClass => schoolClass.Letter, StringComparer.Ordinal)
.Select(schoolClass =>
{
string? teacherName = null;
if (schoolClass.ClassTeacherId is { } teacherId
&& peopleById.TryGetValue(teacherId, out var teacher))
{
teacherName = teacher.Name.Full;
}
return new ClassTeacherSlotResponse(
schoolClass.Id,
schoolClass.Year,
schoolClass.Letter,
schoolClass.ClassTeacherId,
teacherName);
})
.ToArray();
return new StaffingResponse(
allocated,
payroll,
remaining,
uncovered,
applicants,
staff,
positions,
subjects,
classes);
}
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
+107
View File
@@ -682,6 +682,78 @@ internal static class SchoolEndpoints
})
.WithName("UnassignSchoolSubject");
schools.MapPut("/{id:int}/classes/{classId}/class-teacher", async (
int id,
string classId,
AssignClassTeacherRequest request,
string? lang,
HttpContext context,
SessionService sessions,
GameCommandQueue commands,
GameLoopService loop,
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;
}
if (!TryDefName(classId, "classId", out var classIdValue, out var error)
|| !TryPersonId(request.PersonId, out var personId, out error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.AssignClassTeacher(
id,
classIdValue,
personId,
NewCompletion<ClassTeacherOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return ClassTeacherResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("AssignSchoolClassTeacher");
schools.MapDelete("/{id:int}/classes/{classId}/class-teacher", async (
int id,
string classId,
string? lang,
HttpContext context,
SessionService sessions,
GameCommandQueue commands,
GameLoopService loop,
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;
}
if (!TryDefName(classId, "classId", out var classIdValue, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.ClearClassTeacher(id, classIdValue, NewCompletion<ClassTeacherOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return ClassTeacherResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("ClearSchoolClassTeacher");
schools.MapPost("/{id:int}/notices/{noticeId:long}/dismiss", async (
int id,
long noticeId,
@@ -976,6 +1048,41 @@ internal static class SchoolEndpoints
return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale));
}
private static IResult ClassTeacherResult(
int schoolId,
ClassTeacherOutcome outcome,
GameLoopService loop,
string locale)
{
if (outcome.Error != ClassTeacherError.None)
{
return ClassTeacherProblem(outcome);
}
var published = loop.FindPeople(schoolId);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale));
}
private static IResult ClassTeacherProblem(ClassTeacherOutcome outcome) =>
outcome.Error switch
{
ClassTeacherError.UnknownClass =>
Problem(StatusCodes.Status404NotFound, "unknown-class", "That class is not in this school."),
ClassTeacherError.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not on the roster."),
ClassTeacherError.NotTeacher =>
Problem(
StatusCodes.Status400BadRequest,
"not-teacher",
"Only a hired teacher can be the class teacher."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
private static IResult StaffingProblem(StaffingOutcome outcome) =>
outcome.Error switch
{
+11
View File
@@ -110,6 +110,17 @@ internal abstract record GameCommand
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
internal sealed record AssignClassTeacher(
int SchoolId,
string ClassId,
string PersonId,
TaskCompletionSource<ClassTeacherOutcome> Result) : GameCommand;
internal sealed record ClearClassTeacher(
int SchoolId,
string ClassId,
TaskCompletionSource<ClassTeacherOutcome> Result) : GameCommand;
internal sealed record PinLesson(
int SchoolId,
string ClassId,
@@ -286,6 +286,23 @@ internal sealed class GameLoopService(
unassign.Result);
break;
case GameCommand.AssignClassTeacher assignClassTeacher:
HandleClassTeacher(
assignClassTeacher.SchoolId,
new WorkerCommand.AssignClassTeacher(
assignClassTeacher.ClassId,
assignClassTeacher.PersonId,
assignClassTeacher.Result),
assignClassTeacher.Result);
break;
case GameCommand.ClearClassTeacher clearClassTeacher:
HandleClassTeacher(
clearClassTeacher.SchoolId,
new WorkerCommand.ClearClassTeacher(clearClassTeacher.ClassId, clearClassTeacher.Result),
clearClassTeacher.Result);
break;
case GameCommand.PinLesson pin:
HandleTimetable(
pin.SchoolId,
@@ -385,6 +402,14 @@ internal sealed class GameLoopService(
}
}
private void HandleClassTeacher(int schoolId, WorkerCommand command, TaskCompletionSource<ClassTeacherOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(ClassTeachers.UnknownSchool());
}
}
private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource<TimetableOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
+11 -1
View File
@@ -43,12 +43,20 @@ internal static partial class PersonCardReader
var catalog = school.Catalog;
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
int? year = null;
string? letter = null;
string? classTeacherId = null;
string? classTeacherName = null;
if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass))
{
year = schoolClass.Year;
letter = schoolClass.Letter;
classTeacherId = schoolClass.ClassTeacherId;
if (classTeacherId is { } teacherId && people.TryGetValue(teacherId, out var teacher))
{
classTeacherName = teacher.Name.Full;
}
}
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
@@ -98,7 +106,9 @@ internal static partial class PersonCardReader
Connections: Connections(roster, person, catalog, locale),
Orientation: OrientationOf(person, catalog, locale),
TalkCircleMemberIds: circle?.MemberIds ?? [],
TalkTopicId: circle?.TopicId);
TalkTopicId: circle?.TopicId,
ClassTeacherId: classTeacherId,
ClassTeacherName: classTeacherName);
}
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -141,6 +141,15 @@ internal sealed partial class SchoolWorker
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
break;
case WorkerCommand.AssignClassTeacher assignClassTeacher:
assignClassTeacher.Result.TrySetResult(
ApplyAssignClassTeacher(school, assignClassTeacher.ClassId, assignClassTeacher.PersonId));
break;
case WorkerCommand.ClearClassTeacher clearClassTeacher:
clearClassTeacher.Result.TrySetResult(ApplyClearClassTeacher(school, clearClassTeacher.ClassId));
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(
ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period));
@@ -256,6 +265,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(Staffing.UnknownSchool());
break;
case WorkerCommand.AssignClassTeacher assignClassTeacher:
assignClassTeacher.Result.TrySetResult(ClassTeachers.UnknownSchool());
break;
case WorkerCommand.ClearClassTeacher clearClassTeacher:
clearClassTeacher.Result.TrySetResult(ClassTeachers.UnknownSchool());
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
@@ -311,6 +326,12 @@ internal sealed partial class SchoolWorker
case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetException(exception);
break;
case WorkerCommand.AssignClassTeacher assignClassTeacher:
assignClassTeacher.Result.TrySetException(exception);
break;
case WorkerCommand.ClearClassTeacher clearClassTeacher:
clearClassTeacher.Result.TrySetException(exception);
break;
case WorkerCommand.PinLesson pin:
pin.Result.TrySetException(exception);
break;
@@ -344,6 +365,32 @@ internal sealed partial class SchoolWorker
ApplyStaffingChange(school, (catalog, roster, pool) =>
Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
private ClassTeacherOutcome ApplyAssignClassTeacher(School school, string classId, string personId) =>
ApplyClassTeacherChange(school, roster => ClassTeachers.Assign(roster, classId, personId));
private ClassTeacherOutcome ApplyClearClassTeacher(School school, string classId) =>
ApplyClassTeacherChange(school, roster => ClassTeachers.Clear(roster, classId));
private ClassTeacherOutcome ApplyClassTeacherChange(
School school,
Func<Roster, ClassTeacherOutcome> apply)
{
if (school.Roster is null)
{
return ClassTeachers.UnknownSchool();
}
var outcome = apply(school.Roster);
if (outcome.Error == ClassTeacherError.None)
{
school.ApplyRosterData(outcome.Roster);
PersistPeople();
PublishSnapshot();
}
return outcome;
}
private StaffingOutcome ApplyStaffingChange(
School school,
Func<DefCatalog, Roster, ApplicantPool, StaffingOutcome> apply)
+9
View File
@@ -70,6 +70,15 @@ internal abstract record WorkerCommand
string Subject,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
internal sealed record AssignClassTeacher(
string ClassId,
string PersonId,
TaskCompletionSource<ClassTeacherOutcome> Result) : WorkerCommand;
internal sealed record ClearClassTeacher(
string ClassId,
TaskCompletionSource<ClassTeacherOutcome> Result) : WorkerCommand;
internal sealed record PinLesson(
string ClassId,
string Subject,
+11
View File
@@ -320,6 +320,17 @@ public sealed class School : IDisposable
TimetableDirty = true;
}
/// <summary>
/// Writes roster fields that do not change who is in the World (class-teacher slots). No
/// respawn and no timetable dirty — unlike <see cref="ApplyStaffing"/>.
/// </summary>
public void ApplyRosterData(Roster roster)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(roster);
Roster = roster;
}
public void SetTimetable(Timetable timetable)
{
ObjectDisposedException.ThrowIf(_disposed, this);