Update wire protocol to version 5, introducing pupil slots and item counts in map snapshots. Enhance UI components to display pupil slots and item counts in game screens and map editors. Revise localization strings for improved user guidance. Update tests to validate new functionalities and ensure robustness in handling room and item data.
This commit is contained in:
+10
-3
@@ -1,4 +1,4 @@
|
|||||||
# Wire protocol v4
|
# Wire protocol v5
|
||||||
|
|
||||||
The client talks to the server two ways:
|
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
|
`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`.
|
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
|
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
|
||||||
Russian.
|
Russian.
|
||||||
|
|
||||||
@@ -243,9 +247,12 @@ Each node:
|
|||||||
| string | instance id |
|
| string | instance id |
|
||||||
| string | parent id (empty for the yard) |
|
| string | parent id (empty for the yard) |
|
||||||
| string | display name |
|
| 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 |
|
| `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
|
## Guarantees and limits
|
||||||
|
|
||||||
- **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That
|
- **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 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.
|
- 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
|
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.
|
on the client from the snapshot. The school's ECS world is created but still empty.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('t', () => {
|
|||||||
it('interpolates placeholders', () => {
|
it('interpolates placeholders', () => {
|
||||||
setLocale('en');
|
setLocale('en');
|
||||||
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
|
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
|
||||||
|
expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const ru = {
|
|||||||
addRoom: 'Добавить комнату',
|
addRoom: 'Добавить комнату',
|
||||||
removeRoom: 'Удалить комнату',
|
removeRoom: 'Удалить комнату',
|
||||||
slotEmpty: '— пусто —',
|
slotEmpty: '— пусто —',
|
||||||
|
slotCount: 'Количество',
|
||||||
|
|
||||||
backToMenu: '← В главное меню',
|
backToMenu: '← В главное меню',
|
||||||
pause: 'Пауза',
|
pause: 'Пауза',
|
||||||
@@ -76,6 +77,7 @@ const ru = {
|
|||||||
locationActivities: 'Сейчас',
|
locationActivities: 'Сейчас',
|
||||||
locationPositions: 'Должности',
|
locationPositions: 'Должности',
|
||||||
itemsEmpty: 'Пусто.',
|
itemsEmpty: 'Пусто.',
|
||||||
|
pupilSlots: 'Ученических мест: {count}',
|
||||||
charactersEmpty: 'Никого нет.',
|
charactersEmpty: 'Никого нет.',
|
||||||
activitiesEmpty: 'Ничего не происходит.',
|
activitiesEmpty: 'Ничего не происходит.',
|
||||||
positionsEmpty: 'Нет должностей.',
|
positionsEmpty: 'Нет должностей.',
|
||||||
@@ -145,6 +147,7 @@ const en: Messages = {
|
|||||||
addRoom: 'Add room',
|
addRoom: 'Add room',
|
||||||
removeRoom: 'Remove room',
|
removeRoom: 'Remove room',
|
||||||
slotEmpty: '— empty —',
|
slotEmpty: '— empty —',
|
||||||
|
slotCount: 'Count',
|
||||||
|
|
||||||
backToMenu: '← Main menu',
|
backToMenu: '← Main menu',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
@@ -159,6 +162,7 @@ const en: Messages = {
|
|||||||
locationActivities: 'Now',
|
locationActivities: 'Now',
|
||||||
locationPositions: 'Positions',
|
locationPositions: 'Positions',
|
||||||
itemsEmpty: 'Empty.',
|
itemsEmpty: 'Empty.',
|
||||||
|
pupilSlots: 'Pupil places: {count}',
|
||||||
charactersEmpty: 'Nobody here.',
|
charactersEmpty: 'Nobody here.',
|
||||||
activitiesEmpty: 'Nothing is happening.',
|
activitiesEmpty: 'Nothing is happening.',
|
||||||
positionsEmpty: 'No positions.',
|
positionsEmpty: 'No positions.',
|
||||||
|
|||||||
@@ -71,11 +71,13 @@ export interface ModInfo {
|
|||||||
export interface DefInfo {
|
export interface DefInfo {
|
||||||
readonly defName: string;
|
readonly defName: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly pupilSlots?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomSlotInfo {
|
export interface RoomSlotInfo {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
readonly thing: string;
|
readonly thing: string;
|
||||||
|
readonly count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomInfo {
|
export interface RoomInfo {
|
||||||
@@ -95,7 +97,7 @@ export interface MapLayout {
|
|||||||
building: string;
|
building: string;
|
||||||
floor: string;
|
floor: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
slots?: { key: string; thing: string }[];
|
slots?: { key: string; thing: string; count?: number }[];
|
||||||
}[];
|
}[];
|
||||||
links: { a: string; b: string }[];
|
links: { a: string; b: string }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe('decodeServerMessage', () => {
|
|||||||
const id = encoder.encode('yard');
|
const id = encoder.encode('yard');
|
||||||
const parent = encoder.encode('');
|
const parent = encoder.encode('');
|
||||||
const name = 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);
|
const view = new DataView(buffer);
|
||||||
view.setUint8(0, MessageType.ServerMapSnapshot);
|
view.setUint8(0, MessageType.ServerMapSnapshot);
|
||||||
view.setInt32(1, 7, true);
|
view.setInt32(1, 7, true);
|
||||||
@@ -147,6 +147,8 @@ describe('decodeServerMessage', () => {
|
|||||||
offset += 2;
|
offset += 2;
|
||||||
new Uint8Array(buffer).set(name, offset);
|
new Uint8Array(buffer).set(name, offset);
|
||||||
offset += name.length;
|
offset += name.length;
|
||||||
|
view.setUint16(offset, 0, true);
|
||||||
|
offset += 2;
|
||||||
view.setUint8(offset, 0);
|
view.setUint8(offset, 0);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
view.setUint8(offset, 0);
|
view.setUint8(offset, 0);
|
||||||
@@ -155,7 +157,64 @@ describe('decodeServerMessage', () => {
|
|||||||
type: 'map-snapshot',
|
type: 'map-snapshot',
|
||||||
schoolId: 7,
|
schoolId: 7,
|
||||||
nodes: [
|
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: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
* 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 = {
|
export const MessageType = {
|
||||||
ClientHello: 0x01,
|
ClientHello: 0x01,
|
||||||
@@ -69,12 +69,18 @@ export const MapNodeKind = {
|
|||||||
Room: 3,
|
Room: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export interface MapSnapshotItem {
|
||||||
|
readonly name: string;
|
||||||
|
readonly count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MapSnapshotNode {
|
export interface MapSnapshotNode {
|
||||||
readonly kind: number;
|
readonly kind: number;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly parentId: string;
|
readonly parentId: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly items: readonly string[];
|
readonly pupilSlots: number;
|
||||||
|
readonly items: readonly MapSnapshotItem[];
|
||||||
readonly positions: readonly string[];
|
readonly positions: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,13 +242,18 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
|
|||||||
offset = parentId.next;
|
offset = parentId.next;
|
||||||
const name = readString(view, offset);
|
const name = readString(view, offset);
|
||||||
offset = name.next;
|
offset = name.next;
|
||||||
|
ensure(view, offset + 2);
|
||||||
|
const pupilSlots = view.getUint16(offset, true);
|
||||||
|
offset += 2;
|
||||||
const itemCount = readU8(view, offset);
|
const itemCount = readU8(view, offset);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
const items: string[] = [];
|
const items: MapSnapshotItem[] = [];
|
||||||
for (let item = 0; item < itemCount; item++) {
|
for (let item = 0; item < itemCount; item++) {
|
||||||
const value = readString(view, offset);
|
const value = readString(view, offset);
|
||||||
items.push(value.text);
|
|
||||||
offset = value.next;
|
offset = value.next;
|
||||||
|
const count = readU8(view, offset);
|
||||||
|
offset += 1;
|
||||||
|
items.push({ name: value.text, count });
|
||||||
}
|
}
|
||||||
|
|
||||||
const positionCount = readU8(view, offset);
|
const positionCount = readU8(view, offset);
|
||||||
@@ -254,7 +265,15 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
|
|||||||
offset = value.next;
|
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 };
|
return { type: 'map-snapshot', schoolId, nodes };
|
||||||
|
|||||||
@@ -317,6 +317,12 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel__meta {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.tree {
|
.tree {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -542,6 +548,11 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field__row > .input--count {
|
||||||
|
flex: 0 0 4.5rem;
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.field .hint {
|
.field .hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -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 { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import type { School } from '../net/api.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 itemsHeading = el('h3', { class: 'panel__section-title' });
|
||||||
private readonly itemsEmpty = el('p', { class: 'panel__empty' });
|
private readonly itemsEmpty = el('p', { class: 'panel__empty' });
|
||||||
private readonly itemsList = el('ul', { class: 'panel__list' });
|
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 charactersHeading = el('h3', { class: 'panel__section-title' });
|
||||||
private readonly charactersEmpty = el('p', { class: 'panel__empty' });
|
private readonly charactersEmpty = el('p', { class: 'panel__empty' });
|
||||||
private readonly activitiesHeading = el('h3', { class: 'panel__section-title' });
|
private readonly activitiesHeading = el('h3', { class: 'panel__section-title' });
|
||||||
@@ -101,7 +102,7 @@ export class GameScreen {
|
|||||||
'div',
|
'div',
|
||||||
{ class: 'panel__body' },
|
{ class: 'panel__body' },
|
||||||
this.locationName,
|
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.charactersHeading, this.charactersEmpty),
|
||||||
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
|
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
|
||||||
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
|
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);
|
const node = this.nodes.find((candidate) => candidate.id === this.selectedId);
|
||||||
this.locationName.textContent = node?.name ?? '';
|
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 ?? []);
|
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);
|
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 {
|
function paintList(list: HTMLUListElement, empty: HTMLParagraphElement, values: readonly string[]): void {
|
||||||
clear(list);
|
clear(list);
|
||||||
empty.hidden = values.length > 0;
|
empty.hidden = values.length > 0;
|
||||||
|
|||||||
@@ -116,15 +116,29 @@ export function mapEditor(options: MapEditorOptions): {
|
|||||||
|
|
||||||
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
|
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
|
||||||
select.value = fill?.thing ?? '';
|
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);
|
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
|
||||||
if (select.value !== '') {
|
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;
|
room.slots = slots;
|
||||||
emit();
|
emit();
|
||||||
});
|
};
|
||||||
|
|
||||||
|
select.addEventListener('change', writeSlot);
|
||||||
|
countInput.addEventListener('change', writeSlot);
|
||||||
|
|
||||||
rows.push(
|
rows.push(
|
||||||
el(
|
el(
|
||||||
@@ -132,6 +146,7 @@ export function mapEditor(options: MapEditorOptions): {
|
|||||||
{ class: 'field__row' },
|
{ class: 'field__row' },
|
||||||
el('span', { class: 'field__label', text: slot.key }),
|
el('span', { class: 'field__label', text: slot.key }),
|
||||||
select,
|
select,
|
||||||
|
countInput,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,8 +303,8 @@ function hasLink(map: MapLayout, a: string, b: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSlots(def: RoomInfo): { key: string; thing: string }[] {
|
function defaultSlots(def: RoomInfo): { key: string; thing: string; count: number }[] {
|
||||||
return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing }));
|
return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing, count: slot.count }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextId(prefix: string, used: Set<string>): string {
|
function nextId(prefix: string, used: Set<string>): string {
|
||||||
|
|||||||
@@ -352,6 +352,11 @@ public sealed class CatalogLoader
|
|||||||
{
|
{
|
||||||
foreach (var thing in catalog.Things.Values)
|
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)
|
foreach (var action in thing.Actions)
|
||||||
{
|
{
|
||||||
if (!catalog.Actions.ContainsKey(action))
|
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}'.");
|
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)
|
foreach (var position in room.Positions)
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public sealed class ActionDef : Def;
|
|||||||
public sealed class ThingDef : Def
|
public sealed class ThingDef : Def
|
||||||
{
|
{
|
||||||
public IReadOnlyList<string> Actions { get; init; } = [];
|
public IReadOnlyList<string> Actions { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int PupilSlots { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PositionDef : Def;
|
public sealed class PositionDef : Def;
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ public sealed class SlotFill
|
|||||||
public required string Key { get; init; }
|
public required string Key { get; init; }
|
||||||
|
|
||||||
public required string Thing { get; init; }
|
public required string Thing { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many of <see cref="Thing"/> occupy this slot. Missing or non-positive JSON is 1, so
|
||||||
|
/// older maps without a count still round-trip.
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; init; } = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class MapLink
|
public sealed class MapLink
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ public static class MapValidator
|
|||||||
{
|
{
|
||||||
throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' uses unknown or abstract ThingDef '{fill.Thing}'.");
|
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}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ public sealed class MapViewNode
|
|||||||
|
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
|
|
||||||
public IReadOnlyList<string> Items { get; init; } = [];
|
public IReadOnlyList<MapViewItem> Items { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Sum of <c>ThingDef.PupilSlots × fill count</c> for this node. Zero off rooms.</summary>
|
||||||
|
public int PupilSlots { get; init; }
|
||||||
|
|
||||||
public IReadOnlyList<string> Positions { get; init; } = [];
|
public IReadOnlyList<string> Positions { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One stacked thing in a room. The client formats <c>Парта ×16</c>; this is the data.</summary>
|
||||||
|
public sealed record MapViewItem(string Name, int Count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the location tree the client draws. Labels come from the frozen catalog in the
|
/// 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.
|
/// requested locale; people and in-place activities are not part of this view.
|
||||||
@@ -47,6 +53,7 @@ public static class MapView
|
|||||||
parentId: string.Empty,
|
parentId: string.Empty,
|
||||||
LabelOf(catalog, locale, DefKind.Territory, territory.Def),
|
LabelOf(catalog, locale, DefKind.Territory, territory.Def),
|
||||||
items: [],
|
items: [],
|
||||||
|
pupilSlots: 0,
|
||||||
PositionsOf(catalog, locale, DefKind.Territory, territory.Def)),
|
PositionsOf(catalog, locale, DefKind.Territory, territory.Def)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +65,7 @@ public static class MapView
|
|||||||
territory.Id,
|
territory.Id,
|
||||||
LabelOf(catalog, locale, DefKind.Building, building.Def),
|
LabelOf(catalog, locale, DefKind.Building, building.Def),
|
||||||
[],
|
[],
|
||||||
|
pupilSlots: 0,
|
||||||
PositionsOf(catalog, locale, DefKind.Building, building.Def)));
|
PositionsOf(catalog, locale, DefKind.Building, building.Def)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,23 +77,20 @@ public static class MapView
|
|||||||
floor.Building,
|
floor.Building,
|
||||||
FloorName(catalog, locale, floor),
|
FloorName(catalog, locale, floor),
|
||||||
[],
|
[],
|
||||||
|
pupilSlots: 0,
|
||||||
PositionsOf(catalog, locale, DefKind.Floor, floor.Def)));
|
PositionsOf(catalog, locale, DefKind.Floor, floor.Def)));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var room in map.Rooms)
|
foreach (var room in map.Rooms)
|
||||||
{
|
{
|
||||||
var items = new List<string>(room.Slots.Count);
|
var (items, pupilSlots) = RoomContents(catalog, locale, room);
|
||||||
foreach (var fill in room.Slots)
|
|
||||||
{
|
|
||||||
items.Add(LabelOf(catalog, locale, DefKind.Thing, fill.Thing));
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.Add(Node(
|
nodes.Add(Node(
|
||||||
MapNodeKind.Room,
|
MapNodeKind.Room,
|
||||||
room.Id,
|
room.Id,
|
||||||
room.Floor,
|
room.Floor,
|
||||||
RoomName(catalog, locale, room),
|
RoomName(catalog, locale, room),
|
||||||
items,
|
items,
|
||||||
|
pupilSlots,
|
||||||
PositionsOf(catalog, locale, DefKind.Room, room.Def)));
|
PositionsOf(catalog, locale, DefKind.Room, room.Def)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +102,8 @@ public static class MapView
|
|||||||
string id,
|
string id,
|
||||||
string parentId,
|
string parentId,
|
||||||
string name,
|
string name,
|
||||||
IReadOnlyList<string> items,
|
IReadOnlyList<MapViewItem> items,
|
||||||
|
int pupilSlots,
|
||||||
IReadOnlyList<string> positions) =>
|
IReadOnlyList<string> positions) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -106,9 +112,33 @@ public static class MapView
|
|||||||
ParentId = parentId,
|
ParentId = parentId,
|
||||||
Name = name,
|
Name = name,
|
||||||
Items = items,
|
Items = items,
|
||||||
|
PupilSlots = pupilSlots,
|
||||||
Positions = positions,
|
Positions = positions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static (IReadOnlyList<MapViewItem> Items, int PupilSlots) RoomContents(
|
||||||
|
DefCatalog catalog,
|
||||||
|
string locale,
|
||||||
|
RoomNode room)
|
||||||
|
{
|
||||||
|
var items = new List<MapViewItem>(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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A floor's <see cref="FloorNode.Label"/> is its designation, not a replacement name — the
|
/// A floor's <see cref="FloorNode.Label"/> 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
|
/// vanilla map says "1". On its own that is a stray digit in the client's tree, so it follows
|
||||||
|
|||||||
@@ -44,15 +44,21 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId);
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tree node in a map snapshot. Kind is <c>0</c> territory, <c>1</c> building, <c>2</c> floor, <c>3</c> room.
|
/// Tree node in a map snapshot. Kind is <c>0</c> territory, <c>1</c> building, <c>2</c> floor, <c>3</c> room.
|
||||||
/// <paramref name="ParentId"/> is empty for the yard.
|
/// <paramref name="ParentId"/> is empty for the yard.
|
||||||
|
/// <paramref name="PupilSlots"/> is how many pupils can take a lesson here — summed from things
|
||||||
|
/// on the server, not by the client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record MapSnapshotNode(
|
public sealed record MapSnapshotNode(
|
||||||
byte Kind,
|
byte Kind,
|
||||||
string Id,
|
string Id,
|
||||||
string ParentId,
|
string ParentId,
|
||||||
string Name,
|
string Name,
|
||||||
IReadOnlyList<string> Items,
|
ushort PupilSlots,
|
||||||
|
IReadOnlyList<MapSnapshotItem> Items,
|
||||||
IReadOnlyList<string> Positions);
|
IReadOnlyList<string> Positions);
|
||||||
|
|
||||||
|
/// <summary>One stacked thing in a room. <paramref name="Count"/> is 1–255.</summary>
|
||||||
|
public sealed record MapSnapshotItem(string Name, byte Count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One school's map, labelled in the Hello locale. Sent once when that school is opened, not every tick.
|
/// 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.
|
/// People and in-place activities are omitted — the client keeps those sections empty.
|
||||||
|
|||||||
@@ -116,11 +116,12 @@ public static class ProtocolCodec
|
|||||||
{
|
{
|
||||||
size += sizeof(byte);
|
size += sizeof(byte);
|
||||||
size += StringSize(node.Id) + StringSize(node.ParentId) + StringSize(node.Name);
|
size += StringSize(node.Id) + StringSize(node.ParentId) + StringSize(node.Name);
|
||||||
|
size += sizeof(ushort);
|
||||||
|
|
||||||
size += sizeof(byte);
|
size += sizeof(byte);
|
||||||
foreach (var item in node.Items)
|
foreach (var item in node.Items)
|
||||||
{
|
{
|
||||||
size += StringSize(item);
|
size += StringSize(item.Name) + sizeof(byte);
|
||||||
}
|
}
|
||||||
|
|
||||||
size += sizeof(byte);
|
size += sizeof(byte);
|
||||||
@@ -156,10 +157,12 @@ public static class ProtocolCodec
|
|||||||
writer.WriteString(node.Id);
|
writer.WriteString(node.Id);
|
||||||
writer.WriteString(node.ParentId);
|
writer.WriteString(node.ParentId);
|
||||||
writer.WriteString(node.Name);
|
writer.WriteString(node.Name);
|
||||||
|
writer.WriteUInt16(node.PupilSlots);
|
||||||
writer.WriteByte((byte)node.Items.Count);
|
writer.WriteByte((byte)node.Items.Count);
|
||||||
foreach (var item in node.Items)
|
foreach (var item in node.Items)
|
||||||
{
|
{
|
||||||
writer.WriteString(item);
|
writer.WriteString(item.Name);
|
||||||
|
writer.WriteByte(item.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.WriteByte((byte)node.Positions.Count);
|
writer.WriteByte((byte)node.Positions.Count);
|
||||||
@@ -263,11 +266,14 @@ public static class ProtocolCodec
|
|||||||
var id = reader.ReadString();
|
var id = reader.ReadString();
|
||||||
var parentId = reader.ReadString();
|
var parentId = reader.ReadString();
|
||||||
var name = reader.ReadString();
|
var name = reader.ReadString();
|
||||||
|
var pupilSlots = reader.ReadUInt16();
|
||||||
var itemCount = reader.ReadByte();
|
var itemCount = reader.ReadByte();
|
||||||
var items = new string[itemCount];
|
var items = new MapSnapshotItem[itemCount];
|
||||||
for (var item = 0; item < itemCount; item++)
|
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();
|
var positionCount = reader.ReadByte();
|
||||||
@@ -277,7 +283,7 @@ public static class ProtocolCodec
|
|||||||
positions[position] = reader.ReadString();
|
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);
|
return new ServerMapSnapshotMessage(schoolId, nodes);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
|
|||||||
public static class ProtocolConstants
|
public static class ProtocolConstants
|
||||||
{
|
{
|
||||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||||
public const byte Version = 4;
|
public const byte Version = 5;
|
||||||
|
|
||||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||||
public const int MaxMessageSize = 8 * 1024;
|
public const int MaxMessageSize = 8 * 1024;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ internal sealed record CatalogResponse(
|
|||||||
Placeable(catalog.Buildings.Values, catalog, locale),
|
Placeable(catalog.Buildings.Values, catalog, locale),
|
||||||
Placeable(catalog.Floors.Values, catalog, locale),
|
Placeable(catalog.Floors.Values, catalog, locale),
|
||||||
PlaceableRooms(catalog, locale),
|
PlaceableRooms(catalog, locale),
|
||||||
Placeable(catalog.Things.Values, catalog, locale),
|
PlaceableThings(catalog, locale),
|
||||||
map);
|
map);
|
||||||
|
|
||||||
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
||||||
@@ -105,6 +105,13 @@ internal sealed record CatalogResponse(
|
|||||||
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
|
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
private static IReadOnlyList<DefInfoResponse> 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<RoomInfoResponse> PlaceableRooms(DefCatalog catalog, string locale) =>
|
private static IReadOnlyList<RoomInfoResponse> PlaceableRooms(DefCatalog catalog, string locale) =>
|
||||||
catalog.Rooms.Values
|
catalog.Rooms.Values
|
||||||
.Where(def => !def.Abstract)
|
.Where(def => !def.Abstract)
|
||||||
@@ -112,12 +119,12 @@ internal sealed record CatalogResponse(
|
|||||||
.Select(def => new RoomInfoResponse(
|
.Select(def => new RoomInfoResponse(
|
||||||
def.DefName,
|
def.DefName,
|
||||||
catalog.Label(locale, def),
|
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()))
|
def.Positions.ToArray()))
|
||||||
.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(
|
internal sealed record RoomInfoResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
@@ -125,4 +132,4 @@ internal sealed record RoomInfoResponse(
|
|||||||
IReadOnlyList<RoomSlotInfo> Slots,
|
IReadOnlyList<RoomSlotInfo> Slots,
|
||||||
IReadOnlyList<string> Positions);
|
IReadOnlyList<string> Positions);
|
||||||
|
|
||||||
internal sealed record RoomSlotInfo(string Key, string Thing);
|
internal sealed record RoomSlotInfo(string Key, string Thing, int Count);
|
||||||
|
|||||||
@@ -449,12 +449,19 @@ internal sealed class SchoolWorker
|
|||||||
for (var i = 0; i < view.Count; i++)
|
for (var i = 0; i < view.Count; i++)
|
||||||
{
|
{
|
||||||
var node = view[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(
|
nodes[i] = new MapSnapshotNode(
|
||||||
(byte)node.Kind,
|
(byte)node.Kind,
|
||||||
node.Id,
|
node.Id,
|
||||||
node.ParentId,
|
node.ParentId,
|
||||||
node.Name,
|
node.Name,
|
||||||
node.Items,
|
(ushort)node.PupilSlots,
|
||||||
|
items,
|
||||||
node.Positions);
|
node.Positions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
|
{ "key": "teacherChair", "thing": "Chair" },
|
||||||
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
|
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
|
||||||
],
|
],
|
||||||
"positions": ["Teacher"],
|
"positions": ["Teacher"],
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"defName": "ComputerLab",
|
"defName": "ComputerLab",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
|
{ "key": "teacherChair", "thing": "Chair" },
|
||||||
{ "key": "computers", "thing": "Computer", "count": 12 },
|
{ "key": "computers", "thing": "Computer", "count": 12 },
|
||||||
],
|
],
|
||||||
"positions": ["Teacher"],
|
"positions": ["Teacher"],
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[
|
[
|
||||||
{ "defName": "Blackboard" },
|
{ "defName": "Blackboard" },
|
||||||
{ "defName": "StudentDesk", "parent": "Desk", "actions": ["Sit"] },
|
{ "defName": "StudentDesk", "parent": "Desk", "actions": ["Sit"], "pupilSlots": 1 },
|
||||||
{ "defName": "Bookshelf" },
|
{ "defName": "Bookshelf" },
|
||||||
{ "defName": "DiningTable" },
|
{ "defName": "DiningTable" },
|
||||||
{ "defName": "Bench", "actions": ["Sit"] },
|
{ "defName": "Bench", "actions": ["Sit"] },
|
||||||
{ "defName": "Computer" },
|
{ "defName": "Computer", "pupilSlots": 1 },
|
||||||
{ "defName": "MedicalCouch" },
|
{ "defName": "MedicalCouch" },
|
||||||
{ "defName": "Locker" },
|
{ "defName": "Locker" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "directorChair", "thing": "DirectorsChair" },
|
{ "key": "directorChair", "thing": "DirectorsChair" },
|
||||||
{ "key": "desk", "thing": "Desk" },
|
{ "key": "desk", "thing": "Desk" },
|
||||||
{ "key": "guestChair", "thing": "Chair" },
|
{ "key": "guestChair", "thing": "Chair", "count": 2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"floor": "floor-1",
|
"floor": "floor-1",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "table", "thing": "DiningTable" },
|
{ "key": "table", "thing": "DiningTable" },
|
||||||
{ "key": "chairs", "thing": "Chair" },
|
{ "key": "chairs", "thing": "Chair", "count": 6 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,7 +55,8 @@
|
|||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
{ "key": "studentDesks", "thing": "StudentDesk" },
|
{ "key": "teacherChair", "thing": "Chair" },
|
||||||
|
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,7 +68,8 @@
|
|||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "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",
|
"floor": "floor-1",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "counter", "thing": "DiningTable" },
|
{ "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" },
|
{ "id": "restroom-1", "def": "Restroom", "building": "main", "floor": "floor-1", "label": "1" },
|
||||||
@@ -102,7 +104,8 @@
|
|||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
{ "key": "studentDesks", "thing": "StudentDesk" },
|
{ "key": "teacherChair", "thing": "Chair" },
|
||||||
|
{ "key": "studentDesks", "thing": "StudentDesk", "count": 16 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,7 +117,8 @@
|
|||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "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",
|
"floor": "floor-2",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "desk", "thing": "Desk" },
|
{ "key": "desk", "thing": "Desk" },
|
||||||
{ "key": "shelves", "thing": "Bookshelf" },
|
{ "key": "shelves", "thing": "Bookshelf", "count": 4 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,7 +138,8 @@
|
|||||||
"floor": "floor-2",
|
"floor": "floor-2",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "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" },
|
{ "id": "restroom-2", "def": "Restroom", "building": "main", "floor": "floor-2", "label": "2" },
|
||||||
@@ -144,7 +149,7 @@
|
|||||||
"building": "gym",
|
"building": "gym",
|
||||||
"floor": "gym-floor",
|
"floor": "gym-floor",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "benches", "thing": "Bench" },
|
{ "key": "benches", "thing": "Bench", "count": 4 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,7 +158,7 @@
|
|||||||
"building": "gym",
|
"building": "gym",
|
||||||
"floor": "gym-floor",
|
"floor": "gym-floor",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "lockers", "thing": "Locker" },
|
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
Assert.Equal("floor-1", office.ParentId);
|
Assert.Equal("floor-1", office.ParentId);
|
||||||
Assert.Contains("Директор", office.Positions);
|
Assert.Contains("Директор", office.Positions);
|
||||||
Assert.NotEmpty(office.Items);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ public class MapViewTests
|
|||||||
|
|
||||||
var officeRu = ru.Single(node => node.Id == "principals-office");
|
var officeRu = ru.Single(node => node.Id == "principals-office");
|
||||||
Assert.Equal("Кабинет директора", officeRu.Name);
|
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);
|
Assert.Equal(["Директор"], officeRu.Positions);
|
||||||
|
|
||||||
var officeEn = en.Single(node => node.Id == "principals-office");
|
var officeEn = en.Single(node => node.Id == "principals-office");
|
||||||
Assert.Equal("Principal's office", officeEn.Name);
|
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);
|
Assert.Equal(["Principal"], officeEn.Positions);
|
||||||
|
|
||||||
var corridor = ru.Single(node => node.Id == "corridor-1");
|
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");
|
var classroom = ru.Single(node => node.Id == "classroom-1a");
|
||||||
Assert.Equal("Класс 1A", classroom.Name);
|
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.Positions);
|
||||||
Assert.Equal("Classroom 1A", en.Single(node => node.Id == "classroom-1a").Name);
|
Assert.Equal("Classroom 1A", en.Single(node => node.Id == "classroom-1a").Name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,20 @@ public class VanillaCoreTests
|
|||||||
Assert.True(catalog.Rooms.ContainsKey("Classroom"));
|
Assert.True(catalog.Rooms.ContainsKey("Classroom"));
|
||||||
Assert.Equal("Класс", catalog.Label("ru", catalog.Rooms["Classroom"]));
|
Assert.Equal("Класс", catalog.Label("ru", catalog.Rooms["Classroom"]));
|
||||||
Assert.Equal(["Teacher"], catalog.PositionsFor(DefKind.Room, "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.Equal(2, map.Buildings.Count);
|
||||||
Assert.True(map.Rooms.Count >= 18, "Vanilla layout should look like a small school, not a stub.");
|
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.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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,8 +137,15 @@ public class ProtocolCodecTests
|
|||||||
public void MapSnapshot_RoundTripsAndWritesHeaderOffsets()
|
public void MapSnapshot_RoundTripsAndWritesHeaderOffsets()
|
||||||
{
|
{
|
||||||
var message = new ServerMapSnapshotMessage(7, [
|
var message = new ServerMapSnapshotMessage(7, [
|
||||||
new MapSnapshotNode(0, "yard", "", "Двор", [], []),
|
new MapSnapshotNode(0, "yard", "", "Двор", 0, [], []),
|
||||||
new MapSnapshotNode(3, "office", "floor-1", "Кабинет директора", ["Стул"], ["Директор"]),
|
new MapSnapshotNode(
|
||||||
|
3,
|
||||||
|
"office",
|
||||||
|
"floor-1",
|
||||||
|
"Кабинет директора",
|
||||||
|
0,
|
||||||
|
[new MapSnapshotItem("Стул", 2)],
|
||||||
|
["Директор"]),
|
||||||
]);
|
]);
|
||||||
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
var buffer = new byte[ProtocolConstants.MaxMessageSize];
|
||||||
|
|
||||||
@@ -154,17 +161,47 @@ public class ProtocolCodecTests
|
|||||||
Assert.Equal("yard", read.Nodes[0].Id);
|
Assert.Equal("yard", read.Nodes[0].Id);
|
||||||
Assert.Equal("", read.Nodes[0].ParentId);
|
Assert.Equal("", read.Nodes[0].ParentId);
|
||||||
Assert.Equal("Двор", read.Nodes[0].Name);
|
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);
|
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]
|
[Fact]
|
||||||
public void MapSnapshotSize_IsExactlyWhatTheWriterProduces()
|
public void MapSnapshotSize_IsExactlyWhatTheWriterProduces()
|
||||||
{
|
{
|
||||||
var message = new ServerMapSnapshotMessage(7, [
|
var message = new ServerMapSnapshotMessage(7, [
|
||||||
new MapSnapshotNode(0, "yard", "", "Двор", [], []),
|
new MapSnapshotNode(0, "yard", "", "Двор", 0, [], []),
|
||||||
new MapSnapshotNode(2, "floor-1", "main", "Этаж 1", [], []),
|
new MapSnapshotNode(2, "floor-1", "main", "Этаж 1", 0, [], []),
|
||||||
new MapSnapshotNode(3, "office", "floor-1", "Кабинет директора", ["Стол", "Стул"], ["Директор"]),
|
new MapSnapshotNode(
|
||||||
|
3,
|
||||||
|
"office",
|
||||||
|
"floor-1",
|
||||||
|
"Кабинет директора",
|
||||||
|
0,
|
||||||
|
[new MapSnapshotItem("Стол", 1), new MapSnapshotItem("Стул", 2)],
|
||||||
|
["Директор"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var size = ProtocolCodec.MapSnapshotSize(message);
|
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
|
// 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
|
// 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.
|
// school openable instead of killing its worker thread on the first snapshot.
|
||||||
var nodes = new List<MapSnapshotNode> { new(0, "yard", "", "Двор", [], []) };
|
var nodes = new List<MapSnapshotNode> { new(0, "yard", "", "Двор", 0, [], []) };
|
||||||
for (var i = 1; i <= 200; i++)
|
for (var i = 1; i <= 200; i++)
|
||||||
{
|
{
|
||||||
nodes.Add(new MapSnapshotNode(
|
nodes.Add(new MapSnapshotNode(
|
||||||
@@ -187,7 +224,8 @@ public class ProtocolCodecTests
|
|||||||
$"principals-office-{i}",
|
$"principals-office-{i}",
|
||||||
"floor-1",
|
"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(nodes.Count, read.Nodes.Count);
|
||||||
Assert.Equal("principals-office-200", read.Nodes[^1].Id);
|
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]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user