Update wire protocol to version 7 and enhance presence management features
- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure. - Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states. - Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution. - Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream. - Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity. - Updated documentation to reflect the new protocol features and changes in presence management. - Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
This commit is contained in:
@@ -26,6 +26,9 @@ describe('t', () => {
|
||||
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
||||
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
|
||||
.toBe('Кабинет 204 (Математика · 5Б)');
|
||||
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
|
||||
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
|
||||
.toBe('Класс 101 (18 · Математика · 5А)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -169,6 +169,12 @@ const ru = {
|
||||
staffErrorSubject: 'Такого предмета нет.',
|
||||
|
||||
mapOccupancy: '{name} ({activity})',
|
||||
mapHeadcount: '{name} ({count})',
|
||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||
skipTo: 'Пропустить до {date}',
|
||||
presenceAt: '{name}',
|
||||
presenceWalking: 'в пути ({name})',
|
||||
presenceAway: 'вне школы',
|
||||
timetableTitle: 'Расписание',
|
||||
timetableClass: 'Класс',
|
||||
timetableEmpty: 'Нет уроков.',
|
||||
@@ -360,6 +366,12 @@ const en: Messages = {
|
||||
staffErrorSubject: 'That subject is not in the catalog.',
|
||||
|
||||
mapOccupancy: '{name} ({activity})',
|
||||
mapHeadcount: '{name} ({count})',
|
||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||
skipTo: 'Skip to {date}',
|
||||
presenceAt: '{name}',
|
||||
presenceWalking: 'walking ({name})',
|
||||
presenceAway: 'off campus',
|
||||
timetableTitle: 'Timetable',
|
||||
timetableClass: 'Class',
|
||||
timetableEmpty: 'No lessons.',
|
||||
|
||||
@@ -32,6 +32,7 @@ function bootstrap(): void {
|
||||
onLeave: () => leaveSchool(),
|
||||
onSetRunning: (running) => connection.setRunning(running),
|
||||
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
|
||||
onSkip: () => connection.skipEmpty(),
|
||||
});
|
||||
|
||||
const connection = new GameConnection(gameSocketUrl(), {
|
||||
@@ -49,6 +50,11 @@ function bootstrap(): void {
|
||||
game.applyMap(snapshot.schoolId, snapshot.nodes);
|
||||
}
|
||||
},
|
||||
onPresence: (presence) => {
|
||||
if (openSchool?.id === presence.schoolId) {
|
||||
game.applyPresence(presence.schoolId, presence);
|
||||
}
|
||||
},
|
||||
onSchoolGone: (schoolId) => {
|
||||
// Deleted from another tab while we were inside it.
|
||||
if (openSchool?.id === schoolId) {
|
||||
|
||||
@@ -292,6 +292,19 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
|
||||
);
|
||||
}
|
||||
|
||||
export interface DirectoryPerson {
|
||||
readonly id: string;
|
||||
readonly fullName: string;
|
||||
}
|
||||
|
||||
export async function fetchDirectory(schoolId: number, lang: string): Promise<readonly DirectoryPerson[]> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
const response = await request<{ people: readonly DirectoryPerson[] }>(
|
||||
`/api/schools/${schoolId}/directory?${params.toString()}`,
|
||||
);
|
||||
return response.people;
|
||||
}
|
||||
|
||||
export interface StaffingSubject {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
encodePing,
|
||||
encodeSetRunning,
|
||||
encodeSetSpeed,
|
||||
encodeSkipEmpty,
|
||||
ProtocolError,
|
||||
type ClockMessage,
|
||||
type MapSnapshotMessage,
|
||||
type PresenceMessage,
|
||||
type ServerMessage,
|
||||
type WelcomeMessage,
|
||||
} from './protocol.ts';
|
||||
@@ -20,6 +22,7 @@ export interface ConnectionHandlers {
|
||||
onWelcome?(message: WelcomeMessage): void;
|
||||
onClock?(message: ClockMessage): void;
|
||||
onMapSnapshot?(message: MapSnapshotMessage): void;
|
||||
onPresence?(message: PresenceMessage): void;
|
||||
/** The open school was deleted elsewhere; the UI has to leave it. */
|
||||
onSchoolGone?(schoolId: number): void;
|
||||
/** Round-trip time in milliseconds. */
|
||||
@@ -118,6 +121,10 @@ export class GameConnection {
|
||||
this.send(encodeSetSpeed(speedIndex));
|
||||
}
|
||||
|
||||
skipEmpty(): void {
|
||||
this.send(encodeSkipEmpty());
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closedByUs = true;
|
||||
this.stopTimers();
|
||||
@@ -162,6 +169,9 @@ export class GameConnection {
|
||||
case 'map-snapshot':
|
||||
this.handlers.onMapSnapshot?.(message);
|
||||
break;
|
||||
case 'presence':
|
||||
this.handlers.onPresence?.(message);
|
||||
break;
|
||||
case 'school-gone':
|
||||
if (this.openSchoolId === message.schoolId) {
|
||||
this.openSchoolId = null;
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
encodePing,
|
||||
encodeSetRunning,
|
||||
encodeSetSpeed,
|
||||
encodeSkipEmpty,
|
||||
MessageType,
|
||||
PresenceState,
|
||||
ProtocolError,
|
||||
PROTOCOL_VERSION,
|
||||
} from './protocol.ts';
|
||||
@@ -66,6 +68,13 @@ describe('client encoders', () => {
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientSetSpeed);
|
||||
expect(view.getUint8(1)).toBe(4);
|
||||
});
|
||||
|
||||
it('writes a single-byte skip', () => {
|
||||
const view = new DataView(encodeSkipEmpty());
|
||||
|
||||
expect(view.byteLength).toBe(1);
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientSkipEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeServerMessage', () => {
|
||||
@@ -85,16 +94,19 @@ describe('decodeServerMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a clock frame as a UTC instant', () => {
|
||||
// 2012-04-03T06:00:00Z
|
||||
it('reads a clock frame as a UTC instant, including skip permission', () => {
|
||||
// 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z
|
||||
const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
|
||||
const buffer = new ArrayBuffer(15);
|
||||
const skipTargetMs = Date.UTC(2012, 3, 4, 6, 0, 0);
|
||||
const buffer = new ArrayBuffer(24);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerClock);
|
||||
view.setInt32(1, 7, true);
|
||||
view.setBigInt64(5, BigInt(gameTimeMs), true);
|
||||
view.setUint8(13, 1);
|
||||
view.setUint8(14, 2);
|
||||
view.setUint8(15, 1);
|
||||
view.setBigInt64(16, BigInt(skipTargetMs), true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'clock',
|
||||
@@ -102,6 +114,8 @@ describe('decodeServerMessage', () => {
|
||||
gameTime: new Date(gameTimeMs),
|
||||
running: true,
|
||||
speedIndex: 2,
|
||||
skipAllowed: true,
|
||||
skipTarget: new Date(skipTargetMs),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +143,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 + 2 + 1 + 1 + 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);
|
||||
@@ -152,10 +166,6 @@ describe('decodeServerMessage', () => {
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'map-snapshot',
|
||||
@@ -169,9 +179,6 @@ describe('decodeServerMessage', () => {
|
||||
pupilSlots: 0,
|
||||
items: [],
|
||||
positions: [],
|
||||
activitySubject: '',
|
||||
activityClass: '',
|
||||
characters: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -184,7 +191,7 @@ describe('decodeServerMessage', () => {
|
||||
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 + 1 + 1,
|
||||
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);
|
||||
@@ -216,10 +223,6 @@ describe('decodeServerMessage', () => {
|
||||
view.setUint8(offset, 16);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 0);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'map-snapshot',
|
||||
@@ -233,71 +236,41 @@ describe('decodeServerMessage', () => {
|
||||
pupilSlots: 16,
|
||||
items: [{ name: 'Парта', count: 16 }],
|
||||
positions: [],
|
||||
activitySubject: '',
|
||||
activityClass: '',
|
||||
characters: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads occupancy after positions', () => {
|
||||
it('reads a presence frame with node activity and people', () => {
|
||||
const encoder = new TextEncoder();
|
||||
const id = encoder.encode('classroom-101');
|
||||
const parent = encoder.encode('floor-1');
|
||||
const name = encoder.encode('Класс 101');
|
||||
const itemName = encoder.encode('Парта');
|
||||
const nodeId = encoder.encode('classroom-101');
|
||||
const subject = encoder.encode('Математика');
|
||||
const schoolClass = encoder.encode('5А');
|
||||
const teacher = encoder.encode('Иванова');
|
||||
const personId = encoder.encode('f0.c0');
|
||||
const personNode = encoder.encode('classroom-101');
|
||||
const buffer = new ArrayBuffer(
|
||||
7
|
||||
+ 1
|
||||
+ 2 + id.length
|
||||
+ 2 + parent.length
|
||||
+ 2 + name.length
|
||||
+ 2 + nodeId.length
|
||||
+ 2
|
||||
+ 1
|
||||
+ 2 + itemName.length
|
||||
+ 1
|
||||
+ 1
|
||||
+ 1
|
||||
+ 2 + subject.length
|
||||
+ 2 + schoolClass.length
|
||||
+ 1
|
||||
+ 2 + teacher.length,
|
||||
+ 2
|
||||
+ 2 + personId.length
|
||||
+ 2 + personNode.length
|
||||
+ 1,
|
||||
);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerMapSnapshot);
|
||||
view.setUint8(0, MessageType.ServerPresence);
|
||||
view.setInt32(1, 3, true);
|
||||
view.setUint16(5, 1, true);
|
||||
let offset = 7;
|
||||
view.setUint8(offset, 3);
|
||||
offset += 1;
|
||||
view.setUint16(offset, id.length, true);
|
||||
view.setUint16(offset, nodeId.length, true);
|
||||
offset += 2;
|
||||
new Uint8Array(buffer).set(id, offset);
|
||||
offset += id.length;
|
||||
view.setUint16(offset, parent.length, true);
|
||||
new Uint8Array(buffer).set(nodeId, offset);
|
||||
offset += nodeId.length;
|
||||
view.setUint16(offset, 18, 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);
|
||||
offset += 1;
|
||||
view.setUint8(offset, 1);
|
||||
offset += 1;
|
||||
view.setUint16(offset, subject.length, true);
|
||||
@@ -308,29 +281,30 @@ describe('decodeServerMessage', () => {
|
||||
offset += 2;
|
||||
new Uint8Array(buffer).set(schoolClass, offset);
|
||||
offset += schoolClass.length;
|
||||
view.setUint8(offset, 1);
|
||||
offset += 1;
|
||||
view.setUint16(offset, teacher.length, true);
|
||||
view.setUint16(offset, 1, true);
|
||||
offset += 2;
|
||||
new Uint8Array(buffer).set(teacher, offset);
|
||||
view.setUint16(offset, personId.length, true);
|
||||
offset += 2;
|
||||
new Uint8Array(buffer).set(personId, offset);
|
||||
offset += personId.length;
|
||||
view.setUint16(offset, personNode.length, true);
|
||||
offset += 2;
|
||||
new Uint8Array(buffer).set(personNode, offset);
|
||||
offset += personNode.length;
|
||||
view.setUint8(offset, PresenceState.Here);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'map-snapshot',
|
||||
type: 'presence',
|
||||
schoolId: 3,
|
||||
nodes: [
|
||||
{
|
||||
kind: 3,
|
||||
id: 'classroom-101',
|
||||
parentId: 'floor-1',
|
||||
name: 'Класс 101',
|
||||
pupilSlots: 16,
|
||||
items: [{ name: 'Парта', count: 16 }],
|
||||
positions: [],
|
||||
count: 18,
|
||||
activitySubject: 'Математика',
|
||||
activityClass: '5А',
|
||||
characters: ['Иванова'],
|
||||
},
|
||||
],
|
||||
people: [{ id: 'f0.c0', nodeId: 'classroom-101', state: PresenceState.Here }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 6;
|
||||
export const PROTOCOL_VERSION = 7;
|
||||
|
||||
export const MessageType = {
|
||||
ClientHello: 0x01,
|
||||
@@ -14,11 +14,13 @@ export const MessageType = {
|
||||
ClientCloseSchool: 0x04,
|
||||
ClientSetRunning: 0x05,
|
||||
ClientSetSpeed: 0x06,
|
||||
ClientSkipEmpty: 0x07,
|
||||
ServerWelcome: 0x81,
|
||||
ServerPong: 0x82,
|
||||
ServerClock: 0x83,
|
||||
ServerSchoolGone: 0x84,
|
||||
ServerMapSnapshot: 0x85,
|
||||
ServerPresence: 0x86,
|
||||
} as const;
|
||||
|
||||
/** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */
|
||||
@@ -55,6 +57,10 @@ export interface ClockMessage {
|
||||
readonly gameTime: Date;
|
||||
readonly running: boolean;
|
||||
readonly speedIndex: number;
|
||||
/** Server verdict; the client must not recompute this. */
|
||||
readonly skipAllowed: boolean;
|
||||
/** UTC instant the skip would land on; null when skip is refused. */
|
||||
readonly skipTarget: Date | null;
|
||||
}
|
||||
|
||||
export interface SchoolGoneMessage {
|
||||
@@ -82,9 +88,6 @@ export interface MapSnapshotNode {
|
||||
readonly pupilSlots: number;
|
||||
readonly items: readonly MapSnapshotItem[];
|
||||
readonly positions: readonly string[];
|
||||
readonly activitySubject: string;
|
||||
readonly activityClass: string;
|
||||
readonly characters: readonly string[];
|
||||
}
|
||||
|
||||
export interface MapSnapshotMessage {
|
||||
@@ -93,12 +96,38 @@ export interface MapSnapshotMessage {
|
||||
readonly nodes: readonly MapSnapshotNode[];
|
||||
}
|
||||
|
||||
export const PresenceState = {
|
||||
Here: 1,
|
||||
Walking: 2,
|
||||
} as const;
|
||||
|
||||
export interface PresenceNode {
|
||||
readonly id: string;
|
||||
readonly count: number;
|
||||
readonly activitySubject: string;
|
||||
readonly activityClass: string;
|
||||
}
|
||||
|
||||
export interface PresencePerson {
|
||||
readonly id: string;
|
||||
readonly nodeId: string;
|
||||
readonly state: number;
|
||||
}
|
||||
|
||||
export interface PresenceMessage {
|
||||
readonly type: 'presence';
|
||||
readonly schoolId: number;
|
||||
readonly nodes: readonly PresenceNode[];
|
||||
readonly people: readonly PresencePerson[];
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
| WelcomeMessage
|
||||
| PongMessage
|
||||
| ClockMessage
|
||||
| SchoolGoneMessage
|
||||
| MapSnapshotMessage;
|
||||
| MapSnapshotMessage
|
||||
| PresenceMessage;
|
||||
|
||||
/** Thrown when a frame is truncated or carries an unexpected message id. */
|
||||
export class ProtocolError extends Error {}
|
||||
@@ -165,6 +194,12 @@ export function encodeSetSpeed(speedIndex: number): ArrayBuffer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function encodeSkipEmpty(): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(1);
|
||||
new DataView(buffer).setUint8(0, MessageType.ClientSkipEmpty);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
|
||||
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
||||
if (data.byteLength === 0) {
|
||||
@@ -184,6 +219,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
||||
return decodeSchoolGone(view);
|
||||
case MessageType.ServerMapSnapshot:
|
||||
return decodeMapSnapshot(view);
|
||||
case MessageType.ServerPresence:
|
||||
return decodePresence(view);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -211,7 +248,8 @@ function decodePong(view: DataView): PongMessage {
|
||||
}
|
||||
|
||||
function decodeClock(view: DataView): ClockMessage {
|
||||
ensure(view, 15);
|
||||
ensure(view, 24);
|
||||
const skipTargetMs = Number(view.getBigInt64(16, true));
|
||||
|
||||
return {
|
||||
type: 'clock',
|
||||
@@ -219,6 +257,8 @@ function decodeClock(view: DataView): ClockMessage {
|
||||
gameTime: new Date(Number(view.getBigInt64(5, true))),
|
||||
running: view.getUint8(13) !== 0,
|
||||
speedIndex: view.getUint8(14),
|
||||
skipAllowed: view.getUint8(15) !== 0,
|
||||
skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,6 +308,34 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
|
||||
offset = value.next;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
kind,
|
||||
id: id.text,
|
||||
parentId: parentId.text,
|
||||
name: name.text,
|
||||
pupilSlots,
|
||||
items,
|
||||
positions,
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'map-snapshot', schoolId, nodes };
|
||||
}
|
||||
|
||||
function decodePresence(view: DataView): PresenceMessage {
|
||||
ensure(view, 7);
|
||||
|
||||
const schoolId = view.getInt32(1, true);
|
||||
const nodeCount = view.getUint16(5, true);
|
||||
let offset = 7;
|
||||
const nodes: PresenceNode[] = [];
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const id = readString(view, offset);
|
||||
offset = id.next;
|
||||
ensure(view, offset + 2);
|
||||
const count = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const hasActivity = readU8(view, offset);
|
||||
offset += 1;
|
||||
let activitySubject = '';
|
||||
@@ -281,30 +349,24 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
|
||||
activityClass = schoolClass.text;
|
||||
}
|
||||
|
||||
const characterCount = readU8(view, offset);
|
||||
offset += 1;
|
||||
const characters: string[] = [];
|
||||
for (let person = 0; person < characterCount; person++) {
|
||||
const value = readString(view, offset);
|
||||
characters.push(value.text);
|
||||
offset = value.next;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
kind,
|
||||
id: id.text,
|
||||
parentId: parentId.text,
|
||||
name: name.text,
|
||||
pupilSlots,
|
||||
items,
|
||||
positions,
|
||||
activitySubject,
|
||||
activityClass,
|
||||
characters,
|
||||
});
|
||||
nodes.push({ id: id.text, count, activitySubject, activityClass });
|
||||
}
|
||||
|
||||
return { type: 'map-snapshot', schoolId, nodes };
|
||||
ensure(view, offset + 2);
|
||||
const personCount = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
const people: PresencePerson[] = [];
|
||||
for (let i = 0; i < personCount; i++) {
|
||||
const id = readString(view, offset);
|
||||
offset = id.next;
|
||||
const nodeId = readString(view, offset);
|
||||
offset = nodeId.next;
|
||||
const state = readU8(view, offset);
|
||||
offset += 1;
|
||||
people.push({ id: id.text, nodeId: nodeId.text, state });
|
||||
}
|
||||
|
||||
return { type: 'presence', schoolId, nodes, people };
|
||||
}
|
||||
|
||||
function readU8(view: DataView, offset: number): number {
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotItem, type MapSnapshotNode } from '../net/protocol.ts';
|
||||
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||
import {
|
||||
CLOCK_SPEEDS,
|
||||
PresenceState,
|
||||
type ClockMessage,
|
||||
type MapSnapshotItem,
|
||||
type MapSnapshotNode,
|
||||
type PresenceMessage,
|
||||
type PresenceNode,
|
||||
} from '../net/protocol.ts';
|
||||
import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import type { School } from '../net/api.ts';
|
||||
import { fetchDirectory, type School } from '../net/api.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { ManagementPanel } from './managementPanel.ts';
|
||||
import { PeoplePanel } from './peoplePanel.ts';
|
||||
import { formatPersonPlace } from './personCard.ts';
|
||||
|
||||
interface GameScreenOptions {
|
||||
readonly onLeave: () => void;
|
||||
readonly onSetRunning: (running: boolean) => void;
|
||||
readonly onSetSpeed: (speedIndex: number) => void;
|
||||
readonly onSkip: () => void;
|
||||
}
|
||||
|
||||
const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
|
||||
|
||||
/**
|
||||
* The inside of a school: calendar controls plus the manager shell. The tree and location
|
||||
* lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot
|
||||
* on the client. The people panel loads its page over HTTP.
|
||||
* The inside of a school: calendar controls plus the manager shell. The tree comes from one map
|
||||
* snapshot on OpenSchool; presence (~2 Hz) paints headcount and who is here. Clicking a node
|
||||
* only filters on the client. The people panel loads its page over HTTP.
|
||||
*/
|
||||
export class GameScreen {
|
||||
private readonly root = el('section', { class: 'screen game' });
|
||||
@@ -27,6 +38,7 @@ export class GameScreen {
|
||||
private readonly date = el('p', { class: 'clock__date' });
|
||||
private readonly weekday = el('p', { class: 'clock__weekday' });
|
||||
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
|
||||
private readonly skipButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
private readonly speedButtons: HTMLButtonElement[];
|
||||
|
||||
private readonly mapTab = el('button', { class: 'panel__tab', type: 'button' });
|
||||
@@ -62,11 +74,16 @@ export class GameScreen {
|
||||
|
||||
private readonly treeButtons = new Map<string, HTMLButtonElement>();
|
||||
private nodes: readonly MapSnapshotNode[] = [];
|
||||
private presence: PresenceMessage | null = null;
|
||||
private directory = new Map<string, string>();
|
||||
private directoryToken = 0;
|
||||
private selectedId: string | null = null;
|
||||
private schoolId: number | null = null;
|
||||
private running = false;
|
||||
private lastGameTime: Date | null = null;
|
||||
private lastSpeedIndex = 0;
|
||||
private skipAllowed = false;
|
||||
private skipTarget: Date | null = null;
|
||||
private inspected: 'location' | 'person' = 'location';
|
||||
|
||||
constructor(options: GameScreenOptions) {
|
||||
@@ -81,6 +98,8 @@ export class GameScreen {
|
||||
|
||||
this.backButton.addEventListener('click', options.onLeave);
|
||||
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
|
||||
this.skipButton.addEventListener('click', () => options.onSkip());
|
||||
this.skipButton.hidden = true;
|
||||
|
||||
this.root.append(
|
||||
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
|
||||
@@ -93,7 +112,7 @@ export class GameScreen {
|
||||
this.time,
|
||||
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
|
||||
),
|
||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
|
||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons, this.skipButton),
|
||||
),
|
||||
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
|
||||
this.overview,
|
||||
@@ -163,10 +182,14 @@ export class GameScreen {
|
||||
this.paintSelection();
|
||||
|
||||
if (this.lastGameTime !== null) {
|
||||
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex);
|
||||
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex, this.skipAllowed, this.skipTarget);
|
||||
} else {
|
||||
this.playPauseButton.title = t('resume');
|
||||
}
|
||||
|
||||
this.paintSkip();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
}
|
||||
|
||||
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
||||
@@ -174,18 +197,25 @@ export class GameScreen {
|
||||
this.schoolId = school.id;
|
||||
this.schoolName.textContent = school.name;
|
||||
this.nodes = [];
|
||||
this.presence = null;
|
||||
this.directory = new Map();
|
||||
this.selectedId = null;
|
||||
this.skipAllowed = false;
|
||||
this.skipTarget = null;
|
||||
this.rebuildTree();
|
||||
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
|
||||
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null);
|
||||
this.people.show(school.id);
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
this.showTab('map');
|
||||
this.inspect('location');
|
||||
this.showMode('overview');
|
||||
void this.loadDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server sends the tree on OpenSchool and again when the current lesson slot changes.
|
||||
* Occupancy is on the snapshot; this screen must not derive who is where from the clock.
|
||||
* The server sends the tree once on OpenSchool. Occupancy is on the presence stream; this
|
||||
* screen must not derive who is where from the clock.
|
||||
*/
|
||||
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
|
||||
if (this.schoolId !== schoolId) {
|
||||
@@ -200,8 +230,23 @@ export class GameScreen {
|
||||
this.paintSelection();
|
||||
}
|
||||
|
||||
applyPresence(schoolId: number, message: PresenceMessage): void {
|
||||
if (this.schoolId !== schoolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.presence = message;
|
||||
this.paintTreeLabels();
|
||||
this.paintSelection();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
if (message.people.some((person) => !this.directory.has(person.id))) {
|
||||
void this.loadDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
update(clock: ClockMessage): void {
|
||||
this.applyClock(clock.gameTime, clock.running, clock.speedIndex);
|
||||
this.applyClock(clock.gameTime, clock.running, clock.speedIndex, clock.skipAllowed, clock.skipTarget);
|
||||
}
|
||||
|
||||
private rebuildTree(): void {
|
||||
@@ -216,7 +261,7 @@ export class GameScreen {
|
||||
const button = el('button', {
|
||||
class: 'tree__button',
|
||||
type: 'button',
|
||||
text: treeLabel(node),
|
||||
text: treeLabel(node, this.liveNode(node.id)),
|
||||
onClick: () => this.select(node.id),
|
||||
});
|
||||
button.style.paddingLeft = `${8 + depth * 14}px`;
|
||||
@@ -282,29 +327,38 @@ export class GameScreen {
|
||||
}
|
||||
|
||||
const node = this.nodes.find((candidate) => candidate.id === this.selectedId);
|
||||
const live = this.liveNode(this.selectedId);
|
||||
this.locationName.textContent = node?.name ?? '';
|
||||
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 }) : '';
|
||||
const activity = [];
|
||||
if (node?.activitySubject) {
|
||||
activity.push(node.activitySubject);
|
||||
if (live?.activitySubject) {
|
||||
activity.push(live.activitySubject);
|
||||
}
|
||||
|
||||
if (node?.activityClass) {
|
||||
activity.push(node.activityClass);
|
||||
if (live?.activityClass) {
|
||||
activity.push(live.activityClass);
|
||||
}
|
||||
|
||||
paintList(this.activitiesList, this.activitiesEmpty, activity);
|
||||
paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []);
|
||||
paintList(this.charactersList, this.charactersEmpty, this.peopleAt(this.selectedId));
|
||||
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
|
||||
}
|
||||
|
||||
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
|
||||
private applyClock(
|
||||
gameTime: Date,
|
||||
running: boolean,
|
||||
speedIndex: number,
|
||||
skipAllowed: boolean,
|
||||
skipTarget: Date | null,
|
||||
): void {
|
||||
this.running = running;
|
||||
this.lastGameTime = gameTime;
|
||||
this.lastSpeedIndex = speedIndex;
|
||||
this.skipAllowed = skipAllowed;
|
||||
this.skipTarget = skipTarget;
|
||||
|
||||
this.time.textContent = formatGameTimeOfDay(gameTime);
|
||||
this.date.textContent = formatGameDate(gameTime);
|
||||
@@ -316,6 +370,76 @@ export class GameScreen {
|
||||
this.speedButtons.forEach((button, index) => {
|
||||
button.classList.toggle('button--active', index === speedIndex);
|
||||
});
|
||||
this.paintSkip();
|
||||
}
|
||||
|
||||
private paintSkip(): void {
|
||||
const allowed = this.skipAllowed && this.skipTarget !== null;
|
||||
this.skipButton.hidden = !allowed;
|
||||
this.skipButton.textContent = allowed && this.skipTarget !== null
|
||||
? t('skipTo', { date: formatGameDateTime(this.skipTarget) })
|
||||
: '';
|
||||
}
|
||||
|
||||
private paintTreeLabels(): void {
|
||||
for (const [id, button] of this.treeButtons) {
|
||||
const node = this.nodes.find((candidate) => candidate.id === id);
|
||||
if (node !== undefined) {
|
||||
button.textContent = treeLabel(node, this.liveNode(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private liveNode(id: string | null): PresenceNode | undefined {
|
||||
if (id === null || this.presence === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.presence.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
private peopleAt(id: string | null): string[] {
|
||||
if (id === null || this.presence === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = this.presence.people
|
||||
.filter((person) => person.nodeId === id)
|
||||
.map((person) => this.directory.get(person.id) ?? person.id);
|
||||
names.sort((left, right) => left.localeCompare(right));
|
||||
return names;
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
const person = this.presence?.people.find((candidate) => candidate.id === id);
|
||||
if (person === undefined) {
|
||||
return formatPersonPlace('away');
|
||||
}
|
||||
|
||||
const nodeName = this.nodes.find((node) => node.id === person.nodeId)?.name ?? person.nodeId;
|
||||
return formatPersonPlace(person.state === PresenceState.Walking ? 'walking' : 'here', nodeName);
|
||||
}
|
||||
|
||||
private async loadDirectory(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.directoryToken;
|
||||
try {
|
||||
const people = await fetchDirectory(schoolId, getLocale());
|
||||
if (token !== this.directoryToken || this.schoolId !== schoolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.directory = new Map(people.map((person) => [person.id, person.fullName]));
|
||||
this.paintSelection();
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
} catch {
|
||||
// Names stay as ids until the next presence frame retries.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,8 +447,17 @@ function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSna
|
||||
return nodes.filter((node) => node.parentId === parentId);
|
||||
}
|
||||
|
||||
function treeLabel(node: MapSnapshotNode): string {
|
||||
const activity = [node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ');
|
||||
function treeLabel(node: MapSnapshotNode, live: PresenceNode | undefined): string {
|
||||
const count = live?.count ?? 0;
|
||||
const activity = [live?.activitySubject ?? '', live?.activityClass ?? ''].filter((part) => part.length > 0).join(' · ');
|
||||
if (count > 0 && activity.length > 0) {
|
||||
return t('mapHeadcountActivity', { name: node.name, count, activity });
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
return t('mapHeadcount', { name: node.name, count });
|
||||
}
|
||||
|
||||
return activity.length > 0 ? t('mapOccupancy', { name: node.name, activity }) : node.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { getLocale, intlTag } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { renderPersonCard } from './personCard.ts';
|
||||
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
const TEACHER = 'Teacher';
|
||||
@@ -67,6 +67,8 @@ export class ManagementPanel {
|
||||
private loadToken = 0;
|
||||
private cardToken = 0;
|
||||
private busy = false;
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
@@ -139,6 +141,11 @@ export class ManagementPanel {
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
setLocate(locate: (id: string) => string): void {
|
||||
this.locate = locate;
|
||||
this.relocate();
|
||||
}
|
||||
|
||||
private async reload(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
@@ -401,16 +408,30 @@ export class ManagementPanel {
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
clear(this.card);
|
||||
this.painted = card;
|
||||
if (card === null) {
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id));
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id));
|
||||
this.mountPersonTimetable(card);
|
||||
this.appendActions(card.id);
|
||||
}
|
||||
|
||||
private relocate(): void {
|
||||
const line = this.card.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = this.placeOf(this.painted.id);
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||
}
|
||||
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonC
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||
import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||
@@ -59,6 +59,8 @@ export class PeoplePanel {
|
||||
private selectedId: string | null = null;
|
||||
private token = 0;
|
||||
private cardToken = 0;
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
|
||||
constructor(private readonly options: PeoplePanelOptions) {
|
||||
this.ageMinInput.min = '0';
|
||||
@@ -170,6 +172,11 @@ export class PeoplePanel {
|
||||
}
|
||||
}
|
||||
|
||||
setLocate(locate: (id: string) => string): void {
|
||||
this.locate = locate;
|
||||
this.relocate();
|
||||
}
|
||||
|
||||
private onFilterChange(): void {
|
||||
this.page = 1;
|
||||
void this.reload();
|
||||
@@ -349,15 +356,29 @@ export class PeoplePanel {
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
clear(this.card);
|
||||
this.painted = card;
|
||||
if (card === null) {
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id));
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id));
|
||||
this.mountPersonTimetable(card);
|
||||
}
|
||||
|
||||
private relocate(): void {
|
||||
const line = this.card.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = this.placeOf(this.painted.id);
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||
}
|
||||
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
|
||||
@@ -31,11 +31,15 @@ export function renderPersonCard(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
onRelative: (id: string) => void,
|
||||
place?: string,
|
||||
): void {
|
||||
parent.append(
|
||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||
);
|
||||
if (place !== undefined && place.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-place', text: place }));
|
||||
}
|
||||
appendPairs(parent, t('peopleBody'), card.body);
|
||||
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||
@@ -61,6 +65,18 @@ function cardMeta(card: PersonCard): string {
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
export function formatPersonPlace(kind: 'here' | 'walking' | 'away', nodeName = ''): string {
|
||||
if (kind === 'away') {
|
||||
return t('presenceAway');
|
||||
}
|
||||
|
||||
if (kind === 'walking') {
|
||||
return t('presenceWalking', { name: nodeName });
|
||||
}
|
||||
|
||||
return t('presenceAt', { name: nodeName });
|
||||
}
|
||||
|
||||
function section(title: string): HTMLElement {
|
||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user