feat(editor): the simulation's properties in a sheet of their own, with a restart for everyone

Who started the simulation, "Pakete verwerfen" and the dropped packets move
from the toolbar into a properties sheet beside the surface. New in it: restart
the simulation, after asking. The room learns a restart message: stopped as by
stop, every capture, dropped frame and program window emptied, started anew
and named after who restarted; refused for readers and frozen projects, logged,
and waiting for asked blobs like a start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:01:41 +00:00
co-authored by Claude Opus 5.5
parent 317c0d9602
commit 12b26f4abe
23 changed files with 531 additions and 165 deletions
+16 -4
View File
@@ -21,7 +21,7 @@ export type AppKind = (typeof APP_KINDS)[number];
*/
export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'clearCapture', 'input'] as const;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'restart', 'speed', 'drop', 'clearCapture', 'input'] as const;
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
export type ClientMessage =
@@ -35,6 +35,15 @@ export type ClientMessage =
*/
| { readonly v: 1; readonly kind: 'start'; readonly language?: PageLanguage }
| { readonly v: 1; readonly kind: 'stop' }
/**
* Starts the simulation anew (not Java's: there one stops and starts, and
* the data exchange tables and the desktops' windows outlive it): a running
* one is stopped as by `stop` — what the devices wrote stays in the
* document — then every card's capture, the dropped frames and every
* program window are emptied, and it starts as by `start`. From the design
* it only starts, emptied as well.
*/
| { readonly v: 1; readonly kind: 'restart'; readonly language?: PageLanguage }
/** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */
| { readonly v: 1; readonly kind: 'speed'; readonly value: number }
/** Java's "Pakete verwerfen" pressed (on) or let go: every cable drops what it carries while any client holds it. */
@@ -80,7 +89,7 @@ export interface DroppedUnit {
export const ERROR_CODES = ['badMessage', 'badVersion', 'unknownKind', 'badTopic', 'notRunning', 'unknownNode', 'unknownApp', 'unknownAction', 'badArgs', 'engineFailed', 'readOnly', 'frozen'] as const;
/** What a client sends that changes the room: only with a write grant (plan §6.4); a reader subscribes. */
export const WRITING_KINDS: readonly ClientMessageKind[] = ['start', 'stop', 'speed', 'drop', 'clearCapture', 'input'];
export const WRITING_KINDS: readonly ClientMessageKind[] = ['start', 'stop', 'restart', 'speed', 'drop', 'clearCapture', 'input'];
export type ErrorCode = (typeof ERROR_CODES)[number];
export type HostMessage =
@@ -160,9 +169,12 @@ export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } };
}
case 'start':
if (value.language === undefined) return { message: { v: 1, kind: 'start' } };
case 'restart': {
const starting = kind as 'start' | 'restart';
if (value.language === undefined) return { message: { v: 1, kind: starting } };
if (value.language !== 'de' && value.language !== 'en') return bad('badMessage', `language ${String(value.language)}`);
return { message: { v: 1, kind: 'start', language: value.language } };
return { message: { v: 1, kind: starting, language: value.language } };
}
case 'stop':
return { message: { v: 1, kind: 'stop' } };
case 'clearCapture':
+3 -1
View File
@@ -11,6 +11,7 @@ describe("a client's messages", () => {
{ v: 1, kind: 'unsub', topics: ['activity'] },
{ v: 1, kind: 'start' },
{ v: 1, kind: 'stop' },
{ v: 1, kind: 'restart' },
{ v: 1, kind: 'speed', value: 50 },
{ v: 1, kind: 'drop', on: true },
{ v: 1, kind: 'clearCapture' },
@@ -19,7 +20,7 @@ describe("a client's messages", () => {
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each });
// A start in a language, and the language dropped where it is none.
for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } });
for (const kind of ['start', 'restart']) for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind, language })).toEqual({ message: { v: 1, kind, language } });
expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } });
expect(parseClientMessage({ v: 1, kind: 'clearCapture', port: 'p' })).toEqual({ message: { v: 1, kind: 'clearCapture' } });
});
@@ -34,6 +35,7 @@ describe("a client's messages", () => {
expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic');
expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic');
expect(code({ v: 1, kind: 'start', language: 'fr' })).toBe('badMessage');
expect(code({ v: 1, kind: 'restart', language: 'fr' })).toBe('badMessage');
expect(code({ v: 1, kind: 'drop', on: 'yes' })).toBe('badMessage');
expect(code({ v: 1, kind: 'sub', topics: ['dropped', 'app:node/sat', 'app:node/nat'] })).toBe('ok');
expect(code({ v: 1, kind: 'start', language: null })).toBe('badMessage');