This commit is contained in:
Leonid Pershin
2026-08-18 23:51:10 +03:00
parent d8f8db6a48
commit 65feda3756
50 changed files with 1485 additions and 224 deletions
@@ -9,8 +9,8 @@ import {
toDateAndTimeInputs,
} from './gameTime.ts';
// The default start of a new school: 3 April 2012, 06:00 — a Tuesday.
const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0));
// The default start of a new school: 31 March 2012, 06:00 — a Saturday.
const START = new Date(Date.UTC(2012, 2, 31, 6, 0, 0));
const initial = getLocale();
beforeEach(() => setLocale('ru'));
@@ -22,25 +22,25 @@ describe('game time formatting', () => {
});
it('shows the weekday of the game date', () => {
expect(formatGameWeekday(START)).toBe('вторник');
expect(formatGameWeekday(START)).toBe('суббота');
});
it('shows the full date', () => {
expect(formatGameDate(START)).toContain('2012');
expect(formatGameDate(START)).toContain('апреля');
expect(formatGameDate(START)).toContain('марта');
});
it('shows a compact date and time for the school cards', () => {
expect(formatGameDateTime(START)).toContain('03.04.2012');
expect(formatGameDateTime(START)).toContain('31.03.2012');
expect(formatGameDateTime(START)).toContain('06:00');
});
it('formats the same instant in English when the locale is en', () => {
setLocale('en');
expect(formatGameWeekday(START)).toBe('Tuesday');
expect(formatGameDate(START)).toContain('April');
expect(formatGameDateTime(START)).toContain('03/04/2012');
expect(formatGameWeekday(START)).toBe('Saturday');
expect(formatGameDate(START)).toContain('March');
expect(formatGameDateTime(START)).toContain('31/03/2012');
expect(formatGameTimeOfDay(START)).toBe('06:00');
});
@@ -55,7 +55,7 @@ describe('game time formatting', () => {
describe('date inputs', () => {
it('splits an instant into the date and time input values', () => {
expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-04-03', time: '06:00' });
expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-03-31', time: '06:00' });
});
it('rebuilds the same instant from those values', () => {
+2
View File
@@ -65,6 +65,7 @@ const ru = {
removeRoom: 'Удалить комнату',
slotEmpty: '— пусто —',
slotCount: 'Количество',
editorSeats: 'Мест',
backToMenu: '← В главное меню',
pause: 'Пауза',
@@ -193,6 +194,7 @@ const en: Messages = {
removeRoom: 'Remove room',
slotEmpty: '— empty —',
slotCount: 'Count',
editorSeats: 'Seats',
backToMenu: '← Main menu',
pause: 'Pause',
+19
View File
@@ -85,10 +85,27 @@ export interface RoomSlotInfo {
export interface RoomInfo {
readonly defName: string;
readonly label: string;
readonly homeroom: boolean;
readonly seatThing?: string | null;
readonly defaultSeats: number;
readonly slots: readonly RoomSlotInfo[];
readonly positions: readonly string[];
}
export interface SubjectSkillShare {
readonly skill: string;
readonly share: number;
}
export interface SubjectInfo {
readonly defName: string;
readonly label: string;
readonly gradeMin: number;
readonly gradeMax: number;
readonly hoursPerWeek: number;
readonly skills: readonly SubjectSkillShare[];
}
export interface MapLayout {
territory: { id: string; def: string };
buildings: { id: string; def: string }[];
@@ -99,6 +116,7 @@ export interface MapLayout {
building: string;
floor: string;
label?: string;
seats?: number;
slots?: { key: string; thing: string; count?: number }[];
}[];
links: { a: string; b: string }[];
@@ -112,6 +130,7 @@ export interface CatalogResponse {
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
readonly nameSets: readonly DefInfo[];
readonly subjects: readonly SubjectInfo[];
}
export async function fetchMods(): Promise<readonly ModInfo[]> {
+116 -5
View File
@@ -532,8 +532,9 @@ body {
}
.people__card-name {
margin: 0 0 4px;
font-size: 16px;
margin: 0 0 2px;
font-size: 15px;
font-weight: 600;
}
.people__card-meta {
@@ -542,12 +543,122 @@ body {
}
.people__section {
margin-top: 12px;
margin-top: 10px;
}
.people__stats {
.people__section-title {
margin: 0 0 3px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/*
* Name on the left, value on the right, as many columns as the panel is wide. As a bulleted list
* fourteen skills ran the card past the fold with three quarters of the width empty.
*/
.people__pairs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0 16px;
margin: 0;
padding-left: 18px;
font-size: 12px;
}
.people__pair {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 1px 0;
border-bottom: 1px solid rgba(42, 50, 66, 0.5);
}
.people__pair dt {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.people__pair dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.people__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.people__tag {
padding: 1px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
}
.people__needs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 6px 16px;
}
.people__need {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 8px;
font-size: 12px;
}
.people__need-label {
color: var(--text-muted);
}
.people__need-value {
font-variant-numeric: tabular-nums;
}
.people__need-track {
grid-column: 1 / -1;
height: 4px;
margin-top: 2px;
border-radius: 999px;
background: var(--surface);
overflow: hidden;
}
.people__need-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
}
/* An empty need is the one worth spotting without reading the number. */
.people__need-fill--low {
background: var(--danger);
}
.people__rel {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 8px;
font-size: 12px;
}
.people__rel-title {
color: var(--text-muted);
}
.people__rel-list {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
}
.people__link {
+67 -39
View File
@@ -103,52 +103,78 @@ export function mapEditor(options: MapEditorOptions): {
}
const rows: HTMLElement[] = [];
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
const countInput = el('input', { class: 'input input--count', type: 'number' });
countInput.min = '1';
countInput.max = '255';
countInput.step = '1';
countInput.value = String(fill?.count ?? slot.count ?? 1);
countInput.title = t('slotCount');
const writeSlot = (): void => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
const parsed = Number.parseInt(countInput.value, 10);
const count = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (slot.count ?? 1);
countInput.value = String(count);
slots.push({ key: slot.key, thing: select.value, count });
}
room.slots = slots;
if (roomDef.homeroom) {
const seatsInput = el('input', { class: 'input input--count', type: 'number' });
seatsInput.min = '1';
seatsInput.max = '255';
seatsInput.step = '1';
seatsInput.value = String(room.seats ?? roomDef.defaultSeats ?? 16);
seatsInput.title = t('editorSeats');
seatsInput.addEventListener('change', () => {
const parsed = Number.parseInt(seatsInput.value, 10);
const seats = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (roomDef.defaultSeats || 16);
seatsInput.value = String(seats);
room.seats = seats;
room.slots = [];
emit();
};
select.addEventListener('change', writeSlot);
countInput.addEventListener('change', writeSlot);
});
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
countInput,
el('span', { class: 'field__label', text: t('editorSeats') }),
seatsInput,
),
);
} else {
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
const countInput = el('input', { class: 'input input--count', type: 'number' });
countInput.min = '1';
countInput.max = '255';
countInput.step = '1';
countInput.value = String(fill?.count ?? slot.count ?? 1);
countInput.title = t('slotCount');
const writeSlot = (): void => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
const parsed = Number.parseInt(countInput.value, 10);
const count = Number.isFinite(parsed) ? Math.min(255, Math.max(1, parsed)) : (slot.count ?? 1);
countInput.value = String(count);
slots.push({ key: slot.key, thing: select.value, count });
}
room.slots = slots;
emit();
};
select.addEventListener('change', writeSlot);
countInput.addEventListener('change', writeSlot);
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
countInput,
),
);
}
}
return section(
@@ -251,7 +277,9 @@ export function mapEditor(options: MapEditorOptions): {
def: def.defName,
building: building.id,
floor: floor.id,
slots: defaultSlots(def),
...(def.homeroom
? { seats: def.defaultSeats || 16 }
: { slots: defaultSlots(def) }),
});
if (!hasLink(map, map.territory.id, id)) {
map.links.push({ a: map.territory.id, b: id });
+81 -18
View File
@@ -361,16 +361,12 @@ export class PeoplePanel {
el('h3', { class: 'people__card-name', text: card.fullName }),
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
);
appendStats(this.card, t('peopleBody'), card.body.map((row) => `${row.label}: ${row.value}`));
appendStats(this.card, t('peopleSkills'), card.skills.map((row) => `${row.label}: ${row.value}`));
appendStats(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
appendStats(
this.card,
t('peopleNeeds'),
card.needs.map((row) => `${row.label}: ${Math.round(row.value * 100)}%`),
);
appendPairs(this.card, t('peopleBody'), card.body);
appendPairs(this.card, t('peopleSkills'), card.skills);
appendTags(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
appendNeeds(this.card, t('peopleNeeds'), card.needs);
const family = el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: t('peopleFamily') }));
const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id));
appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id));
appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id));
@@ -445,17 +441,79 @@ function cardMeta(card: PersonCard): string {
return bits.join(' · ');
}
function appendStats(parent: HTMLElement, title: string, values: readonly string[]): void {
function section(title: string): HTMLElement {
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
}
/**
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
* the card past the fold while three quarters of its width sat empty.
*/
function appendPairs(
parent: HTMLElement,
title: string,
rows: readonly { readonly label: string; readonly value: string }[],
): void {
if (rows.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of rows) {
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: row.label }),
el('dd', { text: row.value }),
),
);
}
parent.append(section(title), grid);
}
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
if (values.length === 0) {
return;
}
const list = el('ul', { class: 'people__stats' });
const tags = el('div', { class: 'people__tags' });
for (const value of values) {
list.append(el('li', { text: value }));
tags.append(el('span', { class: 'people__tag', text: value }));
}
parent.append(el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: title }), list));
parent.append(section(title), tags);
}
function appendNeeds(
parent: HTMLElement,
title: string,
rows: readonly { readonly label: string; readonly value: number }[],
): void {
if (rows.length === 0) {
return;
}
const grid = el('div', { class: 'people__needs' });
for (const row of rows) {
const share = Math.max(0, Math.min(1, row.value));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
fill.classList.toggle('people__need-fill--low', share < 0.25);
grid.append(
el(
'div',
{ class: 'people__need' },
el('span', { class: 'people__need-label', text: row.label }),
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
el('span', { class: 'people__need-track' }, fill),
),
);
}
parent.append(section(title), grid);
}
function appendRelatives(
@@ -468,10 +526,9 @@ function appendRelatives(
return;
}
const list = el('ul', { class: 'people__stats' });
const list = el('span', { class: 'people__rel-list' });
for (const relative of relatives) {
const item = el('li');
item.append(
list.append(
el('button', {
class: 'people__link',
type: 'button',
@@ -479,8 +536,14 @@ function appendRelatives(
onClick: () => open(relative.id),
}),
);
list.append(item);
}
parent.append(el('h4', { class: 'panel__section-title', text: title }), list);
parent.append(
el(
'div',
{ class: 'people__rel' },
el('span', { class: 'people__rel-title', text: title }),
list,
),
);
}
+32
View File
@@ -307,6 +307,7 @@ public sealed class CatalogLoader
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -351,6 +352,9 @@ public sealed class CatalogLoader
case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
break;
case DefKind.Subject:
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
break;
}
}
@@ -369,6 +373,7 @@ public sealed class CatalogLoader
bodyAttributes,
needs,
nameSets,
subjects,
ru,
en);
}
@@ -421,6 +426,33 @@ public sealed class CatalogLoader
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'.");
}
}
if (room.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is a homeroom and cannot declare named slots.");
}
if (string.IsNullOrWhiteSpace(room.SeatThing) || !catalog.Things.TryGetValue(room.SeatThing, out var seat) || seat.Abstract)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing is missing or unknown.");
}
if (seat.PupilSlots <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' must have pupilSlots.");
}
if (room.DefaultSeats < 1 || room.DefaultSeats > byte.MaxValue)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' defaultSeats must be 1{byte.MaxValue}.");
}
}
else if (!string.IsNullOrWhiteSpace(room.SeatThing) || room.DefaultSeats != 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
}
PeopleDefValidator.Validate(catalog);
+13
View File
@@ -21,6 +21,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, SubjectDef> subjects,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -38,6 +39,7 @@ public sealed class DefCatalog
BodyAttributes = bodyAttributes;
Needs = needs;
NameSets = nameSets;
Subjects = subjects;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -78,6 +80,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; }
public IReadOnlyDictionary<string, SubjectDef> Subjects { get; }
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -98,6 +102,7 @@ public sealed class DefCatalog
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.GetValueOrDefault(defName),
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
DefKind.Subject => Subjects.GetValueOrDefault(defName),
_ => null,
};
@@ -143,6 +148,13 @@ public sealed class DefCatalog
/// </summary>
public string Text(string locale, string key) => TryText(locale, key, out var text) ? text : key;
/// <summary>
/// Whether this locale actually carries the key. <see cref="Text"/> falls back to the key
/// itself, which reads as a label in English and as debug output in Russian — so a test that
/// wants to prove nothing is unlabelled has to ask this instead of comparing strings.
/// </summary>
public bool HasText(string locale, string key) => TryText(locale, key, out _);
private bool TryText(string locale, string key, out string text)
{
var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru;
@@ -164,6 +176,7 @@ public sealed class DefCatalog
BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet,
SubjectDef => DefKind.Subject,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+8
View File
@@ -15,6 +15,7 @@ public enum DefKind
BodyAttribute,
Need,
NameSet,
Subject,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
@@ -64,8 +65,15 @@ public sealed class RoomDef : Def
/// <summary>
/// When true, a map room of this def with pupil slots becomes a roster class.
/// Labs keep <see cref="ThingDef.PupilSlots"/> for lessons without forming a homeroom.
/// Homerooms are a seat count of <see cref="SeatThing"/>, not a table of named slots.
/// </summary>
public bool Homeroom { get; init; }
/// <summary>Thing whose <see cref="ThingDef.PupilSlots"/> × map seats is class capacity.</summary>
public string? SeatThing { get; init; }
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
public int DefaultSeats { get; init; }
}
public sealed class BuildingDef : Def;
+6
View File
@@ -55,6 +55,12 @@ public sealed class RoomNode
/// <summary>Optional designation such as a classroom number. The def label still supplies the noun.</summary>
public string? Label { get; init; }
/// <summary>
/// Homeroom seat count. Capacity is <c>SeatThing.PupilSlots × Seats</c>. Named
/// <see cref="Slots"/> stay on rooms whose furnishing actually varies.
/// </summary>
public int Seats { get; init; }
public IReadOnlyList<SlotFill> Slots { get; init; } = [];
}
+22 -2
View File
@@ -64,7 +64,7 @@ public static class MapValidator
throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building.");
}
ValidateSlotFills(room, catalog);
ValidateRoomContents(room, catalog);
rooms[room.Id] = room;
}
@@ -121,13 +121,33 @@ public static class MapValidator
}
}
private static void ValidateSlotFills(RoomNode room, DefCatalog catalog)
private static void ValidateRoomContents(RoomNode room, DefCatalog catalog)
{
if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef))
{
return;
}
if (roomDef.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new MapValidationException($"Homeroom '{room.Id}' cannot fill named slots.");
}
if (room.Seats < 1 || room.Seats > byte.MaxValue)
{
throw new MapValidationException($"Homeroom '{room.Id}' seats must be 1{byte.MaxValue}.");
}
return;
}
if (room.Seats != 0)
{
throw new MapValidationException($"Room '{room.Id}' is not a homeroom and cannot set seats.");
}
var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal);
foreach (var fill in room.Slots)
{
+14 -13
View File
@@ -121,23 +121,24 @@ public static class MapView
string locale,
RoomNode room)
{
var items = new List<MapViewItem>(room.Slots.Count);
var pupilSlots = 0L;
foreach (var fill in room.Slots)
if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom && !string.IsNullOrWhiteSpace(def.SeatThing))
{
var count = SlotQuantity(fill.Count);
items.Add(new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, fill.Thing), count));
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * count;
}
var seats = RoomOccupancy.Quantity(room.Seats > 0 ? room.Seats : def.DefaultSeats);
return (
[new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, def.SeatThing), seats)],
RoomOccupancy.PupilSlots(catalog, room));
}
return (items, (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue));
}
var items = new List<MapViewItem>(room.Slots.Count);
foreach (var fill in room.Slots)
{
items.Add(new MapViewItem(
LabelOf(catalog, locale, DefKind.Thing, fill.Thing),
RoomOccupancy.Quantity(fill.Count)));
}
private static int SlotQuantity(int count) =>
count < 1 ? 1 : Math.Min(count, byte.MaxValue);
return (items, RoomOccupancy.PupilSlots(catalog, room));
}
/// <summary>
/// A floor's <see cref="FloorNode.Label"/> is its designation, not a replacement name — the
+3
View File
@@ -101,6 +101,9 @@ internal static class PackPaths
case "namesets":
kind = DefKind.NameSet;
return true;
case "subjects":
kind = DefKind.Subject;
return true;
default:
kind = default;
return false;
+42
View File
@@ -29,6 +29,11 @@ internal static class PeopleDefValidator
ValidateNameSet(names);
}
foreach (var subject in catalog.Subjects.Values)
{
ValidateSubject(subject, catalog);
}
RequireBuildInputs(catalog);
}
@@ -218,6 +223,43 @@ internal static class PeopleDefValidator
}
}
private static void ValidateSubject(SubjectDef subject, DefCatalog catalog)
{
if (subject.HoursPerWeek < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' hoursPerWeek cannot be negative.");
}
if (subject.Grades.Min < 1 || subject.Grades.Max > 11 || subject.Grades.Min > subject.Grades.Max)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' grades must be 111 with min ≤ max.");
}
if (subject.Skills.Count == 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' needs at least one skill.");
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var share in subject.Skills)
{
if (string.IsNullOrWhiteSpace(share.Skill) || !seen.Add(share.Skill))
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' has a missing or duplicate skill.");
}
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown SkillDef '{share.Skill}'.");
}
if (share.Share < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
}
}
}
private static void ValidateNameSet(NameSetDef names)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
+16
View File
@@ -122,6 +122,22 @@ public sealed class SkillDef : Def
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
}
public sealed class SubjectSkillShare
{
public required string Skill { get; init; }
public float Share { get; init; }
}
public sealed class SubjectDef : Def
{
public IntRange Grades { get; init; } = new() { Min = 1, Max = 11 };
public int HoursPerWeek { get; init; }
public IReadOnlyList<SubjectSkillShare> Skills { get; init; } = [];
}
public sealed class TraitSkillModifier
{
public required string Skill { get; init; }
+38
View File
@@ -0,0 +1,38 @@
namespace HSchool.Content;
/// <summary>
/// How many pupils a map room can host. Homerooms are a seat count of one thing; other rooms
/// still sum <c>ThingDef.PupilSlots × fill count</c> across named slots.
/// </summary>
public static class RoomOccupancy
{
public static int Quantity(int count) =>
count < 1 ? 1 : Math.Min(count, byte.MaxValue);
public static int PupilSlots(DefCatalog catalog, RoomNode room)
{
if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom)
{
if (string.IsNullOrWhiteSpace(def.SeatThing)
|| !catalog.Things.TryGetValue(def.SeatThing, out var seat)
|| seat.PupilSlots <= 0)
{
return 0;
}
var seats = room.Seats > 0 ? Quantity(room.Seats) : Quantity(def.DefaultSeats);
return (int)Math.Clamp((long)seat.PupilSlots * seats, 0, ushort.MaxValue);
}
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * Quantity(fill.Count);
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ public static class RosterGenerator
/// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in
/// the default school year. Callers with a live clock must pass <paramref name="asOf"/>.
/// </summary>
public static readonly DateTime DefaultAsOf = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
public static readonly DateTime DefaultAsOf = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
public static Roster Generate(
DefCatalog catalog,
+1 -16
View File
@@ -33,7 +33,7 @@ public sealed class SchoolDemand
foreach (var room in map.Rooms)
{
var slots = PupilSlotsOf(catalog, room);
var slots = RoomOccupancy.PupilSlots(catalog, room);
if (catalog.Rooms.TryGetValue(room.Def, out var def))
{
foreach (var position in def.Positions)
@@ -67,19 +67,4 @@ public sealed class SchoolDemand
return new SchoolDemand(classes, seats, staff);
}
private static int PupilSlotsOf(DefCatalog catalog, RoomNode room)
{
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
var count = fill.Count < 1 ? 1 : Math.Min(fill.Count, byte.MaxValue);
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * count;
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
}
+31
View File
@@ -87,6 +87,7 @@ internal sealed record CatalogResponse(
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
IReadOnlyList<SubjectInfoResponse> Subjects,
MapLayout DefaultMap)
{
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
@@ -97,6 +98,7 @@ internal sealed record CatalogResponse(
PlaceableRooms(catalog, locale),
PlaceableThings(catalog, locale),
Placeable(catalog.NameSets.Values, catalog, locale),
PlaceableSubjects(catalog, locale),
map);
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
@@ -121,9 +123,25 @@ internal sealed record CatalogResponse(
.Select(def => new RoomInfoResponse(
def.DefName,
catalog.Label(locale, def),
def.Homeroom,
def.SeatThing,
def.DefaultSeats,
def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing, slot.Count)).ToArray(),
def.Positions.ToArray()))
.ToArray();
private static IReadOnlyList<SubjectInfoResponse> PlaceableSubjects(DefCatalog catalog, string locale) =>
catalog.Subjects.Values
.Where(def => !def.Abstract)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new SubjectInfoResponse(
def.DefName,
catalog.Label(locale, def),
def.Grades.Min,
def.Grades.Max,
def.HoursPerWeek,
def.Skills.Select(share => new SubjectSkillInfo(share.Skill, share.Share)).ToArray()))
.ToArray();
}
internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
@@ -131,7 +149,20 @@ internal sealed record DefInfoResponse(string DefName, string Label, int PupilSl
internal sealed record RoomInfoResponse(
string DefName,
string Label,
bool Homeroom,
string? SeatThing,
int DefaultSeats,
IReadOnlyList<RoomSlotInfo> Slots,
IReadOnlyList<string> Positions);
internal sealed record RoomSlotInfo(string Key, string Thing, int Count);
internal sealed record SubjectSkillInfo(string Skill, float Share);
internal sealed record SubjectInfoResponse(
string DefName,
string Label,
int GradeMin,
int GradeMax,
int HoursPerWeek,
IReadOnlyList<SubjectSkillInfo> Skills);
+1 -1
View File
@@ -10,7 +10,7 @@
"TickRate": 20,
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-04-03T06:00:00",
"DefaultStartDate": "2012-03-31T06:00:00",
"SavesDirectory": "saves",
"ModsDirectory": "mods",
"SaveIntervalSeconds": 30
@@ -4,6 +4,5 @@
{ "defName": "Teacher" },
{ "defName": "Librarian" },
{ "defName": "Nurse" },
{ "defName": "PETeacher" },
{ "defName": "CafeteriaCook" },
]
@@ -1,15 +1,10 @@
[
{
"defName": "Classroom",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"positions": ["Teacher"],
"works": ["TeachLesson"],
"homeroom": true,
"seatThing": "StudentDesk",
"defaultSeats": 16,
"works": ["TeachLesson"],
},
{
"defName": "Library",
@@ -27,7 +22,6 @@
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "computers", "thing": "Computer", "count": 12 },
],
"positions": ["Teacher"],
"works": ["TeachLesson"],
},
]
@@ -3,6 +3,5 @@
"slots": [
{ "key": "benches", "thing": "Bench", "count": 4 },
],
"positions": ["PETeacher"],
"works": ["PELesson"],
}
@@ -0,0 +1,79 @@
[
{
"defName": "PrimarySchool",
"grades": { "min": 1, "max": 4 },
"hoursPerWeek": 20,
"skills": [
{ "skill": "Mathematics", "share": 0.3 },
{ "skill": "RussianLanguage", "share": 0.3 },
{ "skill": "Literature", "share": 0.2 },
{ "skill": "Biology", "share": 0.2 },
],
},
{
"defName": "Mathematics",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 5,
"skills": [{ "skill": "Mathematics", "share": 1 }],
},
{
"defName": "RussianLanguage",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 4,
"skills": [{ "skill": "RussianLanguage", "share": 1 }],
},
{
"defName": "Literature",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "Literature", "share": 1 }],
},
{
"defName": "ForeignLanguage",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "ForeignLanguage", "share": 1 }],
},
{
"defName": "History",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "History", "share": 1 }],
},
{
"defName": "Geography",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Geography", "share": 1 }],
},
{
"defName": "Biology",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Biology", "share": 1 }],
},
{
"defName": "Informatics",
"grades": { "min": 5, "max": 11 },
"hoursPerWeek": 1,
"skills": [{ "skill": "Informatics", "share": 1 }],
},
{
"defName": "Physics",
"grades": { "min": 7, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Physics", "share": 1 }],
},
{
"defName": "Chemistry",
"grades": { "min": 8, "max": 11 },
"hoursPerWeek": 2,
"skills": [{ "skill": "Chemistry", "share": 1 }],
},
{
"defName": "PhysicalEducation",
"grades": { "min": 1, "max": 11 },
"hoursPerWeek": 3,
"skills": [{ "skill": "PhysicalEducation", "share": 1 }],
},
]
@@ -16,8 +16,8 @@
"Teacher": "Teacher",
"Librarian": "Librarian",
"Nurse": "Nurse",
"PETeacher": "PE teacher",
"CafeteriaCook": "Cook",
"PrimarySchool": "Primary",
"PrincipalOfficeWork": "Principal's work",
"TeachLesson": "Teach a lesson",
"WalkSchool": "Walk the school",
@@ -87,6 +87,7 @@
"Toilet": "Toilet",
"Social": "Social",
"Slavic": "Slavic",
"Build": "Build",
"Skinny": "Skinny",
"Average": "Average",
"Athletic": "Athletic",
@@ -16,8 +16,8 @@
"Teacher": "Учитель",
"Librarian": "Библиотекарь",
"Nurse": "Медсестра",
"PETeacher": "Учитель физкультуры",
"CafeteriaCook": "Повар",
"PrimarySchool": "Начальные классы",
"PrincipalOfficeWork": "Работа директора",
"TeachLesson": "Урок",
"WalkSchool": "Обход школы",
@@ -87,6 +87,7 @@
"Toilet": "Туалет",
"Social": "Общение",
"Slavic": "Славянский",
"Build": "Телосложение",
"Skinny": "Худощавое",
"Average": "Обычное",
"Athletic": "Атлетическое",
+11 -66
View File
@@ -52,12 +52,7 @@
"building": "main",
"floor": "floor-1",
"label": "101",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-102",
@@ -65,12 +60,7 @@
"building": "main",
"floor": "floor-1",
"label": "102",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-103",
@@ -78,12 +68,7 @@
"building": "main",
"floor": "floor-1",
"label": "103",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-104",
@@ -91,12 +76,7 @@
"building": "main",
"floor": "floor-1",
"label": "104",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "cafeteria",
@@ -127,12 +107,7 @@
"building": "main",
"floor": "floor-2",
"label": "201",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-202",
@@ -140,12 +115,7 @@
"building": "main",
"floor": "floor-2",
"label": "202",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-203",
@@ -153,12 +123,7 @@
"building": "main",
"floor": "floor-2",
"label": "203",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-204",
@@ -166,12 +131,7 @@
"building": "main",
"floor": "floor-2",
"label": "204",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-205",
@@ -179,12 +139,7 @@
"building": "main",
"floor": "floor-2",
"label": "205",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-206",
@@ -192,12 +147,7 @@
"building": "main",
"floor": "floor-2",
"label": "206",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "classroom-207",
@@ -205,12 +155,7 @@
"building": "main",
"floor": "floor-2",
"label": "207",
"slots": [
{ "key": "board", "thing": "Blackboard" },
{ "key": "teacherDesk", "thing": "Desk" },
{ "key": "teacherChair", "thing": "Chair" },
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
],
"seats": 16,
},
{
"id": "library",
+1 -1
View File
@@ -5,7 +5,7 @@ public sealed class SimulationOptions
{
public const string SectionName = "Simulation";
private DateTime _defaultStartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private DateTime _defaultStartDate = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
/// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20;