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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>): string {
|
||||
|
||||
Reference in New Issue
Block a user