diff --git a/docs/protocol.md b/docs/protocol.md index 4d0fcce..00b8781 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,4 +1,4 @@ -# Wire protocol v4 +# Wire protocol v5 The client talks to the server two ways: @@ -65,6 +65,10 @@ Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/de `core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`. +Room slots include a `count` (how many of the default thing the def places). Things carry +`pupilSlots` — how many pupils that thing hosts for lessons. The create editor copies both onto +the map instance; the snapshot's per-node pupil-slot total is computed on the server. + `lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is Russian. @@ -243,9 +247,12 @@ Each node: | string | instance id | | string | parent id (empty for the yard) | | string | display name | -| `u8` | item count, then that many strings | +| `u16` | pupil slots — how many pupils can take a lesson here. Summed from things on the server. | +| `u8` | item count, then that many records of: string name + `u8` count | | `u8` | position count, then that many strings | +Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items. + ## Guarantees and limits - **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That @@ -258,7 +265,7 @@ Each node: the oldest, because a stale clock is worthless once a newer one exists. - The map snapshot uses a separate reliable queue so ticks cannot crowd it out. -## Not in v4 yet +## Not in v5 yet Authentication, Sit orders, an event log, and `OpenLocation` on the server — the tree is filtered on the client from the snapshot. The school's ECS world is created but still empty. diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index 117e602..fcd17d8 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -20,6 +20,7 @@ describe('t', () => { it('interpolates placeholders', () => { setLocale('en'); expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.'); + expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16'); }); }); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 1a1fb62..b0408ac 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -62,6 +62,7 @@ const ru = { addRoom: 'Добавить комнату', removeRoom: 'Удалить комнату', slotEmpty: '— пусто —', + slotCount: 'Количество', backToMenu: '← В главное меню', pause: 'Пауза', @@ -76,6 +77,7 @@ const ru = { locationActivities: 'Сейчас', locationPositions: 'Должности', itemsEmpty: 'Пусто.', + pupilSlots: 'Ученических мест: {count}', charactersEmpty: 'Никого нет.', activitiesEmpty: 'Ничего не происходит.', positionsEmpty: 'Нет должностей.', @@ -145,6 +147,7 @@ const en: Messages = { addRoom: 'Add room', removeRoom: 'Remove room', slotEmpty: '— empty —', + slotCount: 'Count', backToMenu: '← Main menu', pause: 'Pause', @@ -159,6 +162,7 @@ const en: Messages = { locationActivities: 'Now', locationPositions: 'Positions', itemsEmpty: 'Empty.', + pupilSlots: 'Pupil places: {count}', charactersEmpty: 'Nobody here.', activitiesEmpty: 'Nothing is happening.', positionsEmpty: 'No positions.', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index fd193ec..cb482de 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -71,11 +71,13 @@ export interface ModInfo { export interface DefInfo { readonly defName: string; readonly label: string; + readonly pupilSlots?: number; } export interface RoomSlotInfo { readonly key: string; readonly thing: string; + readonly count: number; } export interface RoomInfo { @@ -95,7 +97,7 @@ export interface MapLayout { building: string; floor: string; label?: string; - slots?: { key: string; thing: string }[]; + slots?: { key: string; thing: string; count?: number }[]; }[]; links: { a: string; b: string }[]; } diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 573957d..940414d 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -129,7 +129,7 @@ describe('decodeServerMessage', () => { const id = encoder.encode('yard'); const parent = encoder.encode(''); const name = encoder.encode('Двор'); - const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 1 + 1); + const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1); const view = new DataView(buffer); view.setUint8(0, MessageType.ServerMapSnapshot); view.setInt32(1, 7, true); @@ -147,6 +147,8 @@ describe('decodeServerMessage', () => { offset += 2; new Uint8Array(buffer).set(name, offset); offset += name.length; + view.setUint16(offset, 0, true); + offset += 2; view.setUint8(offset, 0); offset += 1; view.setUint8(offset, 0); @@ -155,7 +157,64 @@ describe('decodeServerMessage', () => { type: 'map-snapshot', schoolId: 7, nodes: [ - { kind: 0, id: 'yard', parentId: '', name: 'Двор', items: [], positions: [] }, + { kind: 0, id: 'yard', parentId: '', name: 'Двор', pupilSlots: 0, items: [], positions: [] }, + ], + }); + }); + + it('reads stacked items and pupil slots at the documented offsets', () => { + const encoder = new TextEncoder(); + const id = encoder.encode('classroom-1a'); + const parent = encoder.encode('floor-1'); + const name = encoder.encode('Класс 1A'); + const itemName = encoder.encode('Парта'); + const buffer = new ArrayBuffer( + 7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1, + ); + const view = new DataView(buffer); + view.setUint8(0, MessageType.ServerMapSnapshot); + view.setInt32(1, 1, true); + view.setUint16(5, 1, true); + let offset = 7; + view.setUint8(offset, 3); + offset += 1; + view.setUint16(offset, id.length, true); + offset += 2; + new Uint8Array(buffer).set(id, offset); + offset += id.length; + view.setUint16(offset, parent.length, true); + offset += 2; + new Uint8Array(buffer).set(parent, offset); + offset += parent.length; + view.setUint16(offset, name.length, true); + offset += 2; + new Uint8Array(buffer).set(name, offset); + offset += name.length; + view.setUint16(offset, 16, true); + offset += 2; + view.setUint8(offset, 1); + offset += 1; + view.setUint16(offset, itemName.length, true); + offset += 2; + new Uint8Array(buffer).set(itemName, offset); + offset += itemName.length; + view.setUint8(offset, 16); + offset += 1; + view.setUint8(offset, 0); + + expect(decodeServerMessage(buffer)).toEqual({ + type: 'map-snapshot', + schoolId: 1, + nodes: [ + { + kind: 3, + id: 'classroom-1a', + parentId: 'floor-1', + name: 'Класс 1A', + pupilSlots: 16, + items: [{ name: 'Парта', count: 16 }], + positions: [], + }, ], }); }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index 6aa312d..cb01956 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -5,7 +5,7 @@ * changed together and documented in `docs/protocol.md`. All numbers are little-endian. */ -export const PROTOCOL_VERSION = 4; +export const PROTOCOL_VERSION = 5; export const MessageType = { ClientHello: 0x01, @@ -69,12 +69,18 @@ export const MapNodeKind = { Room: 3, } as const; +export interface MapSnapshotItem { + readonly name: string; + readonly count: number; +} + export interface MapSnapshotNode { readonly kind: number; readonly id: string; readonly parentId: string; readonly name: string; - readonly items: readonly string[]; + readonly pupilSlots: number; + readonly items: readonly MapSnapshotItem[]; readonly positions: readonly string[]; } @@ -236,13 +242,18 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage { offset = parentId.next; const name = readString(view, offset); offset = name.next; + ensure(view, offset + 2); + const pupilSlots = view.getUint16(offset, true); + offset += 2; const itemCount = readU8(view, offset); offset += 1; - const items: string[] = []; + const items: MapSnapshotItem[] = []; for (let item = 0; item < itemCount; item++) { const value = readString(view, offset); - items.push(value.text); offset = value.next; + const count = readU8(view, offset); + offset += 1; + items.push({ name: value.text, count }); } const positionCount = readU8(view, offset); @@ -254,7 +265,15 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage { offset = value.next; } - nodes.push({ kind, id: id.text, parentId: parentId.text, name: name.text, items, positions }); + nodes.push({ + kind, + id: id.text, + parentId: parentId.text, + name: name.text, + pupilSlots, + items, + positions, + }); } return { type: 'map-snapshot', schoolId, nodes }; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index cec7cae..df43785 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -317,6 +317,12 @@ body { font-size: 13px; } +.panel__meta { + margin: 6px 0 0; + color: var(--text-muted); + font-size: 13px; +} + .tree { margin: 0; padding: 0; @@ -542,6 +548,11 @@ body { flex: 1; } +.field__row > .input--count { + flex: 0 0 4.5rem; + width: 4.5rem; +} + .field .hint { margin: 0; font-size: 13px; diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 6fa5140..c1c90a0 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -1,4 +1,4 @@ -import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotNode } from '../net/protocol.ts'; +import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotItem, type MapSnapshotNode } from '../net/protocol.ts'; import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; import { t } from '../i18n/strings.ts'; import type { School } from '../net/api.ts'; @@ -36,6 +36,7 @@ export class GameScreen { private readonly itemsHeading = el('h3', { class: 'panel__section-title' }); private readonly itemsEmpty = el('p', { class: 'panel__empty' }); private readonly itemsList = el('ul', { class: 'panel__list' }); + private readonly pupilSlotsLine = el('p', { class: 'panel__meta' }); private readonly charactersHeading = el('h3', { class: 'panel__section-title' }); private readonly charactersEmpty = el('p', { class: 'panel__empty' }); private readonly activitiesHeading = el('h3', { class: 'panel__section-title' }); @@ -101,7 +102,7 @@ export class GameScreen { 'div', { class: 'panel__body' }, this.locationName, - el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList), + el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine), el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty), el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList), @@ -210,7 +211,10 @@ export class GameScreen { const node = this.nodes.find((candidate) => candidate.id === this.selectedId); this.locationName.textContent = node?.name ?? ''; - paintList(this.itemsList, this.itemsEmpty, node?.items ?? []); + paintList(this.itemsList, this.itemsEmpty, (node?.items ?? []).map(formatItem)); + const pupilSlots = node?.pupilSlots ?? 0; + this.pupilSlotsLine.hidden = pupilSlots <= 0; + this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : ''; paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []); } @@ -236,6 +240,10 @@ function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSna return nodes.filter((node) => node.parentId === parentId); } +function formatItem(item: MapSnapshotItem): string { + return item.count === 1 ? item.name : `${item.name} ×${item.count}`; +} + function paintList(list: HTMLUListElement, empty: HTMLParagraphElement, values: readonly string[]): void { clear(list); empty.hidden = values.length > 0; diff --git a/src/HSchool.Client/src/ui/mapEditor.ts b/src/HSchool.Client/src/ui/mapEditor.ts index 18b7c61..e5104d1 100644 --- a/src/HSchool.Client/src/ui/mapEditor.ts +++ b/src/HSchool.Client/src/ui/mapEditor.ts @@ -116,15 +116,29 @@ export function mapEditor(options: MapEditorOptions): { const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key); select.value = fill?.thing ?? ''; - select.addEventListener('change', () => { + + 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 !== '') { - slots.push({ key: slot.key, thing: 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( @@ -132,6 +146,7 @@ export function mapEditor(options: MapEditorOptions): { { class: 'field__row' }, el('span', { class: 'field__label', text: slot.key }), select, + countInput, ), ); } @@ -288,8 +303,8 @@ function hasLink(map: MapLayout, a: string, b: string): boolean { ); } -function defaultSlots(def: RoomInfo): { key: string; thing: string }[] { - return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing })); +function defaultSlots(def: RoomInfo): { key: string; thing: string; count: number }[] { + return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing, count: slot.count })); } function nextId(prefix: string, used: Set): string { diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index b4bd9af..721a23c 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -352,6 +352,11 @@ public sealed class CatalogLoader { foreach (var thing in catalog.Things.Values) { + if (thing.PupilSlots < 0 || thing.PupilSlots > byte.MaxValue) + { + throw new ContentLoadException($"ThingDef '{thing.DefName}' pupilSlots must be 0–{byte.MaxValue}."); + } + foreach (var action in thing.Actions) { if (!catalog.Actions.ContainsKey(action)) @@ -369,6 +374,11 @@ public sealed class CatalogLoader { throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' references unknown ThingDef '{slot.Thing}'."); } + + if (slot.Count < 0 || slot.Count > byte.MaxValue) + { + throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' count must be 0–{byte.MaxValue}."); + } } foreach (var position in room.Positions) diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index b00286d..24864cc 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -27,6 +27,12 @@ public sealed class ActionDef : Def; public sealed class ThingDef : Def { public IReadOnlyList Actions { get; init; } = []; + + /// + /// How many pupils this thing hosts for lessons. Zero for teacher's furniture — a chair + /// must not inflate class size just because someone sits in it. + /// + public int PupilSlots { get; init; } } public sealed class PositionDef : Def; diff --git a/src/HSchool.Content/MapLayout.cs b/src/HSchool.Content/MapLayout.cs index dd18dee..ec0ef11 100644 --- a/src/HSchool.Content/MapLayout.cs +++ b/src/HSchool.Content/MapLayout.cs @@ -63,6 +63,12 @@ public sealed class SlotFill public required string Key { get; init; } public required string Thing { get; init; } + + /// + /// How many of occupy this slot. Missing or non-positive JSON is 1, so + /// older maps without a count still round-trip. + /// + public int Count { get; init; } = 1; } public sealed class MapLink diff --git a/src/HSchool.Content/MapValidator.cs b/src/HSchool.Content/MapValidator.cs index b916970..fe8ef2d 100644 --- a/src/HSchool.Content/MapValidator.cs +++ b/src/HSchool.Content/MapValidator.cs @@ -140,6 +140,11 @@ public static class MapValidator { throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' uses unknown or abstract ThingDef '{fill.Thing}'."); } + + if (fill.Count > byte.MaxValue) + { + throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' count {fill.Count} is above {byte.MaxValue}."); + } } } diff --git a/src/HSchool.Content/MapView.cs b/src/HSchool.Content/MapView.cs index d7fefc6..b861f90 100644 --- a/src/HSchool.Content/MapView.cs +++ b/src/HSchool.Content/MapView.cs @@ -21,11 +21,17 @@ public sealed class MapViewNode public required string Name { get; init; } - public IReadOnlyList Items { get; init; } = []; + public IReadOnlyList Items { get; init; } = []; + + /// Sum of ThingDef.PupilSlots × fill count for this node. Zero off rooms. + public int PupilSlots { get; init; } public IReadOnlyList Positions { get; init; } = []; } +/// One stacked thing in a room. The client formats Парта ×16; this is the data. +public sealed record MapViewItem(string Name, int Count); + /// /// Builds the location tree the client draws. Labels come from the frozen catalog in the /// requested locale; people and in-place activities are not part of this view. @@ -47,6 +53,7 @@ public static class MapView parentId: string.Empty, LabelOf(catalog, locale, DefKind.Territory, territory.Def), items: [], + pupilSlots: 0, PositionsOf(catalog, locale, DefKind.Territory, territory.Def)), }; @@ -58,6 +65,7 @@ public static class MapView territory.Id, LabelOf(catalog, locale, DefKind.Building, building.Def), [], + pupilSlots: 0, PositionsOf(catalog, locale, DefKind.Building, building.Def))); } @@ -69,23 +77,20 @@ public static class MapView floor.Building, FloorName(catalog, locale, floor), [], + pupilSlots: 0, PositionsOf(catalog, locale, DefKind.Floor, floor.Def))); } foreach (var room in map.Rooms) { - var items = new List(room.Slots.Count); - foreach (var fill in room.Slots) - { - items.Add(LabelOf(catalog, locale, DefKind.Thing, fill.Thing)); - } - + var (items, pupilSlots) = RoomContents(catalog, locale, room); nodes.Add(Node( MapNodeKind.Room, room.Id, room.Floor, RoomName(catalog, locale, room), items, + pupilSlots, PositionsOf(catalog, locale, DefKind.Room, room.Def))); } @@ -97,7 +102,8 @@ public static class MapView string id, string parentId, string name, - IReadOnlyList items, + IReadOnlyList items, + int pupilSlots, IReadOnlyList positions) => new() { @@ -106,9 +112,33 @@ public static class MapView ParentId = parentId, Name = name, Items = items, + PupilSlots = pupilSlots, Positions = positions, }; + private static (IReadOnlyList Items, int PupilSlots) RoomContents( + DefCatalog catalog, + string locale, + RoomNode room) + { + var items = new List(room.Slots.Count); + var pupilSlots = 0L; + foreach (var fill in room.Slots) + { + 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; + } + } + + return (items, (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue)); + } + + private static int SlotQuantity(int count) => + count < 1 ? 1 : Math.Min(count, byte.MaxValue); + /// /// A floor's is its designation, not a replacement name — the /// vanilla map says "1". On its own that is a stray digit in the client's tree, so it follows diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index cb852b2..d6e1b3e 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -44,15 +44,21 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId); /// /// Tree node in a map snapshot. Kind is 0 territory, 1 building, 2 floor, 3 room. /// is empty for the yard. +/// is how many pupils can take a lesson here — summed from things +/// on the server, not by the client. /// public sealed record MapSnapshotNode( byte Kind, string Id, string ParentId, string Name, - IReadOnlyList Items, + ushort PupilSlots, + IReadOnlyList Items, IReadOnlyList Positions); +/// One stacked thing in a room. is 1–255. +public sealed record MapSnapshotItem(string Name, byte Count); + /// /// One school's map, labelled in the Hello locale. Sent once when that school is opened, not every tick. /// People and in-place activities are omitted — the client keeps those sections empty. diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index a9b3971..2200130 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -116,11 +116,12 @@ public static class ProtocolCodec { size += sizeof(byte); size += StringSize(node.Id) + StringSize(node.ParentId) + StringSize(node.Name); + size += sizeof(ushort); size += sizeof(byte); foreach (var item in node.Items) { - size += StringSize(item); + size += StringSize(item.Name) + sizeof(byte); } size += sizeof(byte); @@ -156,10 +157,12 @@ public static class ProtocolCodec writer.WriteString(node.Id); writer.WriteString(node.ParentId); writer.WriteString(node.Name); + writer.WriteUInt16(node.PupilSlots); writer.WriteByte((byte)node.Items.Count); foreach (var item in node.Items) { - writer.WriteString(item); + writer.WriteString(item.Name); + writer.WriteByte(item.Count); } writer.WriteByte((byte)node.Positions.Count); @@ -263,11 +266,14 @@ public static class ProtocolCodec var id = reader.ReadString(); var parentId = reader.ReadString(); var name = reader.ReadString(); + var pupilSlots = reader.ReadUInt16(); var itemCount = reader.ReadByte(); - var items = new string[itemCount]; + var items = new MapSnapshotItem[itemCount]; for (var item = 0; item < itemCount; item++) { - items[item] = reader.ReadString(); + var itemName = reader.ReadString(); + var count = reader.ReadByte(); + items[item] = new MapSnapshotItem(itemName, count); } var positionCount = reader.ReadByte(); @@ -277,7 +283,7 @@ public static class ProtocolCodec positions[position] = reader.ReadString(); } - nodes[i] = new MapSnapshotNode(kind, id, parentId, name, items, positions); + nodes[i] = new MapSnapshotNode(kind, id, parentId, name, pupilSlots, items, positions); } return new ServerMapSnapshotMessage(schoolId, nodes); diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index 1455f02..dcd7dae 100644 --- a/src/HSchool.Protocol/ProtocolConstants.cs +++ b/src/HSchool.Protocol/ProtocolConstants.cs @@ -4,7 +4,7 @@ namespace HSchool.Protocol; public static class ProtocolConstants { /// Bumped on every breaking change to the binary layout. - public const byte Version = 4; + public const byte Version = 5; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 848b38a..7ecffa5 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -94,7 +94,7 @@ internal sealed record CatalogResponse( Placeable(catalog.Buildings.Values, catalog, locale), Placeable(catalog.Floors.Values, catalog, locale), PlaceableRooms(catalog, locale), - Placeable(catalog.Things.Values, catalog, locale), + PlaceableThings(catalog, locale), map); private static IReadOnlyList Placeable(IEnumerable defs, DefCatalog catalog, string locale) @@ -105,6 +105,13 @@ internal sealed record CatalogResponse( .Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def))) .ToArray(); + private static IReadOnlyList PlaceableThings(DefCatalog catalog, string locale) => + catalog.Things.Values + .Where(def => !def.Abstract) + .OrderBy(def => def.DefName, StringComparer.Ordinal) + .Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def), def.PupilSlots)) + .ToArray(); + private static IReadOnlyList PlaceableRooms(DefCatalog catalog, string locale) => catalog.Rooms.Values .Where(def => !def.Abstract) @@ -112,12 +119,12 @@ internal sealed record CatalogResponse( .Select(def => new RoomInfoResponse( def.DefName, catalog.Label(locale, def), - def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing)).ToArray(), + def.Slots.Select(slot => new RoomSlotInfo(slot.Key, slot.Thing, slot.Count)).ToArray(), def.Positions.ToArray())) .ToArray(); } -internal sealed record DefInfoResponse(string DefName, string Label); +internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0); internal sealed record RoomInfoResponse( string DefName, @@ -125,4 +132,4 @@ internal sealed record RoomInfoResponse( IReadOnlyList Slots, IReadOnlyList Positions); -internal sealed record RoomSlotInfo(string Key, string Thing); +internal sealed record RoomSlotInfo(string Key, string Thing, int Count); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 6664573..b6851b5 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -449,12 +449,19 @@ internal sealed class SchoolWorker for (var i = 0; i < view.Count; i++) { var node = view[i]; + var items = new MapSnapshotItem[node.Items.Count]; + for (var item = 0; item < node.Items.Count; item++) + { + items[item] = new MapSnapshotItem(node.Items[item].Name, (byte)node.Items[item].Count); + } + nodes[i] = new MapSnapshotNode( (byte)node.Kind, node.Id, node.ParentId, node.Name, - node.Items, + (ushort)node.PupilSlots, + items, node.Positions); } diff --git a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc index 25cc2f8..3ea58de 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc @@ -4,6 +4,7 @@ "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, ], "positions": ["Teacher"], @@ -22,6 +23,7 @@ "defName": "ComputerLab", "slots": [ { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, { "key": "computers", "thing": "Computer", "count": 12 }, ], "positions": ["Teacher"], diff --git a/src/HSchool.Server/mods/core/defs/things/furniture.jsonc b/src/HSchool.Server/mods/core/defs/things/furniture.jsonc index 5876694..1f9825b 100644 --- a/src/HSchool.Server/mods/core/defs/things/furniture.jsonc +++ b/src/HSchool.Server/mods/core/defs/things/furniture.jsonc @@ -1,10 +1,10 @@ [ { "defName": "Blackboard" }, - { "defName": "StudentDesk", "parent": "Desk", "actions": ["Sit"] }, + { "defName": "StudentDesk", "parent": "Desk", "actions": ["Sit"], "pupilSlots": 1 }, { "defName": "Bookshelf" }, { "defName": "DiningTable" }, { "defName": "Bench", "actions": ["Sit"] }, - { "defName": "Computer" }, + { "defName": "Computer", "pupilSlots": 1 }, { "defName": "MedicalCouch" }, { "defName": "Locker" }, ] diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc index 15e18ff..44252fa 100644 --- a/src/HSchool.Server/mods/core/maps/default.jsonc +++ b/src/HSchool.Server/mods/core/maps/default.jsonc @@ -23,7 +23,7 @@ "slots": [ { "key": "directorChair", "thing": "DirectorsChair" }, { "key": "desk", "thing": "Desk" }, - { "key": "guestChair", "thing": "Chair" }, + { "key": "guestChair", "thing": "Chair", "count": 2 }, ], }, { @@ -43,7 +43,7 @@ "floor": "floor-1", "slots": [ { "key": "table", "thing": "DiningTable" }, - { "key": "chairs", "thing": "Chair" }, + { "key": "chairs", "thing": "Chair", "count": 6 }, ], }, { @@ -55,7 +55,8 @@ "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, - { "key": "studentDesks", "thing": "StudentDesk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, ], }, { @@ -67,7 +68,8 @@ "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, - { "key": "studentDesks", "thing": "StudentDesk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, ], }, { @@ -77,7 +79,7 @@ "floor": "floor-1", "slots": [ { "key": "counter", "thing": "DiningTable" }, - { "key": "seats", "thing": "Chair" }, + { "key": "seats", "thing": "Chair", "count": 8 }, ], }, { "id": "restroom-1", "def": "Restroom", "building": "main", "floor": "floor-1", "label": "1" }, @@ -102,7 +104,8 @@ "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, - { "key": "studentDesks", "thing": "StudentDesk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, ], }, { @@ -114,7 +117,8 @@ "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, - { "key": "studentDesks", "thing": "StudentDesk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, ], }, { @@ -124,7 +128,7 @@ "floor": "floor-2", "slots": [ { "key": "desk", "thing": "Desk" }, - { "key": "shelves", "thing": "Bookshelf" }, + { "key": "shelves", "thing": "Bookshelf", "count": 4 }, ], }, { @@ -134,7 +138,8 @@ "floor": "floor-2", "slots": [ { "key": "teacherDesk", "thing": "Desk" }, - { "key": "computers", "thing": "Computer" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "computers", "thing": "Computer", "count": 12 }, ], }, { "id": "restroom-2", "def": "Restroom", "building": "main", "floor": "floor-2", "label": "2" }, @@ -144,7 +149,7 @@ "building": "gym", "floor": "gym-floor", "slots": [ - { "key": "benches", "thing": "Bench" }, + { "key": "benches", "thing": "Bench", "count": 4 }, ], }, { @@ -153,7 +158,7 @@ "building": "gym", "floor": "gym-floor", "slots": [ - { "key": "lockers", "thing": "Locker" }, + { "key": "lockers", "thing": "Locker", "count": 12 }, ], }, ], diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 2f723a1..f47f643 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -106,6 +106,10 @@ public class GameSocketTests(AppHostFixture fixture) Assert.Equal("floor-1", office.ParentId); Assert.Contains("Директор", office.Positions); Assert.NotEmpty(office.Items); + var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-1a"); + Assert.Equal(16, classroom.PupilSlots); + Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16); + Assert.Contains(classroom.Items, item => item.Name == "Стул" && item.Count == 1); } [Fact] diff --git a/tests/HSchool.Content.Tests/MapViewTests.cs b/tests/HSchool.Content.Tests/MapViewTests.cs index e0aef1f..a46e08f 100644 --- a/tests/HSchool.Content.Tests/MapViewTests.cs +++ b/tests/HSchool.Content.Tests/MapViewTests.cs @@ -23,12 +23,17 @@ public class MapViewTests var officeRu = ru.Single(node => node.Id == "principals-office"); Assert.Equal("Кабинет директора", officeRu.Name); - Assert.Equal(["Кресло директора", "Стол", "Стул"], officeRu.Items); + Assert.Equal( + [new MapViewItem("Кресло директора", 1), new MapViewItem("Стол", 1), new MapViewItem("Стул", 2)], + officeRu.Items); + Assert.Equal(0, officeRu.PupilSlots); Assert.Equal(["Директор"], officeRu.Positions); var officeEn = en.Single(node => node.Id == "principals-office"); Assert.Equal("Principal's office", officeEn.Name); - Assert.Equal(["Principal's chair", "Desk", "Chair"], officeEn.Items); + Assert.Equal( + [new MapViewItem("Principal's chair", 1), new MapViewItem("Desk", 1), new MapViewItem("Chair", 2)], + officeEn.Items); Assert.Equal(["Principal"], officeEn.Positions); var corridor = ru.Single(node => node.Id == "corridor-1"); @@ -38,7 +43,15 @@ public class MapViewTests var classroom = ru.Single(node => node.Id == "classroom-1a"); Assert.Equal("Класс 1A", classroom.Name); - Assert.Equal(["Доска", "Стол", "Парта"], classroom.Items); + Assert.Equal( + [ + new MapViewItem("Доска", 1), + new MapViewItem("Стол", 1), + new MapViewItem("Стул", 1), + new MapViewItem("Парта", 16), + ], + classroom.Items); + Assert.Equal(16, classroom.PupilSlots); Assert.Equal(["Учитель"], classroom.Positions); Assert.Equal("Classroom 1A", en.Single(node => node.Id == "classroom-1a").Name); } diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 1a2ed05..2e4bec9 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -25,8 +25,20 @@ public class VanillaCoreTests Assert.True(catalog.Rooms.ContainsKey("Classroom")); Assert.Equal("Класс", catalog.Label("ru", catalog.Rooms["Classroom"])); Assert.Equal(["Teacher"], catalog.PositionsFor(DefKind.Room, "Classroom")); + Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots); + Assert.Equal(1, catalog.Things["Computer"].PupilSlots); + Assert.Equal(0, catalog.Things["Desk"].PupilSlots); + Assert.Equal(0, catalog.Things["Chair"].PupilSlots); + Assert.Contains(catalog.Rooms["Classroom"].Slots, slot => slot.Key == "teacherChair" && slot.Thing == "Chair"); + Assert.Equal(16, catalog.Rooms["Classroom"].Slots.Single(slot => slot.Key == "studentDesks").Count); Assert.Equal(2, map.Buildings.Count); Assert.True(map.Rooms.Count >= 18, "Vanilla layout should look like a small school, not a stub."); Assert.Contains(map.Rooms, room => room.Id == "classroom-1a" && room.Label == "1A"); + Assert.Equal( + 16, + map.Rooms.Single(room => room.Id == "classroom-1a").Slots.Single(slot => slot.Key == "studentDesks").Count); + Assert.Contains( + map.Rooms.Single(room => room.Id == "classroom-1a").Slots, + slot => slot.Key == "teacherChair" && slot.Thing == "Chair"); } } diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index f435b1f..0f41ca9 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -137,8 +137,15 @@ public class ProtocolCodecTests public void MapSnapshot_RoundTripsAndWritesHeaderOffsets() { var message = new ServerMapSnapshotMessage(7, [ - new MapSnapshotNode(0, "yard", "", "Двор", [], []), - new MapSnapshotNode(3, "office", "floor-1", "Кабинет директора", ["Стул"], ["Директор"]), + new MapSnapshotNode(0, "yard", "", "Двор", 0, [], []), + new MapSnapshotNode( + 3, + "office", + "floor-1", + "Кабинет директора", + 0, + [new MapSnapshotItem("Стул", 2)], + ["Директор"]), ]); var buffer = new byte[ProtocolConstants.MaxMessageSize]; @@ -154,17 +161,47 @@ public class ProtocolCodecTests Assert.Equal("yard", read.Nodes[0].Id); Assert.Equal("", read.Nodes[0].ParentId); Assert.Equal("Двор", read.Nodes[0].Name); - Assert.Equal(["Стул"], read.Nodes[1].Items); + Assert.Equal(0, read.Nodes[0].PupilSlots); + Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items); Assert.Equal(["Директор"], read.Nodes[1].Positions); } + [Fact] + public void MapSnapshot_WritesPupilSlotsAndStackedItemCount() + { + var message = new ServerMapSnapshotMessage(1, [ + new MapSnapshotNode( + 3, + "classroom-1a", + "floor-1", + "Класс 1A", + 16, + [new MapSnapshotItem("Парта", 16)], + []), + ]); + var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)]; + + var length = ProtocolCodec.WriteMapSnapshot(buffer, message); + var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length)); + + Assert.Equal(16, read.Nodes[0].PupilSlots); + Assert.Equal([new MapSnapshotItem("Парта", 16)], read.Nodes[0].Items); + } + [Fact] public void MapSnapshotSize_IsExactlyWhatTheWriterProduces() { var message = new ServerMapSnapshotMessage(7, [ - new MapSnapshotNode(0, "yard", "", "Двор", [], []), - new MapSnapshotNode(2, "floor-1", "main", "Этаж 1", [], []), - new MapSnapshotNode(3, "office", "floor-1", "Кабинет директора", ["Стол", "Стул"], ["Директор"]), + new MapSnapshotNode(0, "yard", "", "Двор", 0, [], []), + new MapSnapshotNode(2, "floor-1", "main", "Этаж 1", 0, [], []), + new MapSnapshotNode( + 3, + "office", + "floor-1", + "Кабинет директора", + 0, + [new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)], + ["Директор"]), ]); var size = ProtocolCodec.MapSnapshotSize(message); @@ -179,7 +216,7 @@ public class ProtocolCodecTests // A player who keeps clicking "Add room" in the create editor gets past 8 KiB somewhere // around sixty furnished rooms. Sizing the buffer from the message is what keeps that // school openable instead of killing its worker thread on the first snapshot. - var nodes = new List { new(0, "yard", "", "Двор", [], []) }; + var nodes = new List { new(0, "yard", "", "Двор", 0, [], []) }; for (var i = 1; i <= 200; i++) { nodes.Add(new MapSnapshotNode( @@ -187,7 +224,8 @@ public class ProtocolCodecTests $"principals-office-{i}", "floor-1", "Кабинет директора", - ["Кресло директора", "Стол", "Стул"], + 0, + [new MapSnapshotItem("Кресло директора", 1), new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)], ["Директор"])); } @@ -201,7 +239,9 @@ public class ProtocolCodecTests Assert.Equal(nodes.Count, read.Nodes.Count); Assert.Equal("principals-office-200", read.Nodes[^1].Id); - Assert.Equal(["Кресло директора", "Стол", "Стул"], read.Nodes[^1].Items); + Assert.Equal( + [new MapSnapshotItem("Кресло директора", 1), new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)], + read.Nodes[^1].Items); } [Fact]