feat(editor): F4.6a simulation mode and a computer's desktop

Java's mode buttons start and stop the room, now in a worker of the
editor's page (LocalSimHost), with Java's speed slider. While the
simulation runs the design is locked as in Java: no palette, no sheet,
nothing moves, no undo. Cables that carry a frame light up in Java's
green, and a tap on a computer opens its desktop: Java's title, the
software installation first, then the installed programs in the order
of Java's list for the language, a taskbar with the network icon and
the network as the simulation runs it. The room sends that over a new
topic, node:<id>. Desktops stay over a return to the design and show
again at the next start. The programs' windows follow from F4.6b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:41:41 +00:00
co-authored by Claude Opus 5.5
parent 6e97e3565d
commit dfd5ec03ce
44 changed files with 1190 additions and 41 deletions
+34 -2
View File
@@ -16,7 +16,7 @@
// keep rising (plan §6.2).
import * as Y from 'yjs';
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
import { docSnapshot } from '@mdg-filius/doc';
import {
parseClientMessage,
@@ -25,6 +25,7 @@ import {
type ClientMessage,
type ErrorCode,
type HostMessage,
type NodeSettings,
type SimMode,
type Topic,
type TraceFrame,
@@ -47,6 +48,8 @@ interface Client {
readonly traceSent: Map<Id, number>;
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
readonly appSent: Map<string, number>;
/** Per computer, the settings last sent (as text). */
readonly nodeSent: Map<Id, string>;
}
/** A program with a window: its lines. */
@@ -79,7 +82,7 @@ export class SimulationRoom {
/** A new client; it gets the room's state at once. */
connect(send: (message: HostMessage) => void): number {
const id = this.nextClient++;
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map() });
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map() });
send(this.state());
return id;
}
@@ -117,6 +120,7 @@ export class SimulationRoom {
client.topics.add(topic);
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
if (topic.startsWith('app:')) client.appSent.delete(topic);
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
}
this.flush([client], true);
return;
@@ -260,6 +264,14 @@ export class SimulationRoom {
for (const topic of client.topics) {
if (topic === 'activity') {
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
} else if (topic.startsWith('node:')) {
const node = topic.slice('node:'.length);
const settings = this.nodeSettings(node);
if (settings === undefined) continue;
const text = JSON.stringify(settings);
if (client.nodeSent.get(node) === text) continue;
client.nodeSent.set(node, text);
client.send(message({ kind: 'node', node, settings }));
} else if (topic.startsWith('trace:')) {
const portId = topic.slice('trace:'.length);
const reset = !client.traceSent.has(portId);
@@ -288,6 +300,26 @@ export class SimulationRoom {
}
}
/**
* A device's first card as the engine runs it (a computer's settings, which
* DHCP may change); undefined for a device the simulation never had.
*/
private nodeSettings(id: Id): NodeSettings | undefined {
const snapshot = this.engine.state.topologySnapshot;
const node = snapshot.nodes.find((each) => each.id === id);
if (node === undefined) return undefined;
const port = node.ports[0];
const device = snapshot.deviceConfigurations.get(id);
const card = port === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, port.id));
return {
ipAddress: device?.ipAddress ?? card?.ipAddress ?? '',
subnetMask: device?.subnetMask ?? card?.subnetMask ?? '',
defaultGateway: device?.defaultGateway ?? '',
dnsServer: device?.dnsServer ?? '',
macAddress: port === undefined ? '' : (port.macAddress ?? stableGeneratedMacAddress(port.id)),
};
}
/** The cables whose cards got frames since the last tick; sent when that changes. */
private updateActivity(): void {
const links = new Set<string>();
+2 -2
View File
@@ -117,8 +117,8 @@ describe('the simulation in a worker of the page', () => {
const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close());
const got: HostMessage[] = [];
host.onMessage((message) => got.push(message));
channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'node', id: 'x' } });
expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind node' });
channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'nodes', id: 'x' } });
expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nodes' });
host.close();
channel.port2.close();
});
+24
View File
@@ -306,4 +306,28 @@ describe('a simulation room', () => {
expect(ofKind(got, 'error')).toHaveLength(1);
expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' });
});
it("sends a computer's network as it runs, anew when DHCP changes it, and nothing before a start", () => {
const { got, send, run, node } = setUp(exampleDoc('java-examples/einfaches_rechnernetz_komplett.fls'));
const notebook = node('Notebook 1');
const server = node('Server');
send({ kind: 'sub', topics: [`node:${notebook.id}`, `node:${server.id}`] });
expect(ofKind(got, 'node')).toEqual([]);
send({ kind: 'start' });
run(20_000);
const sent = ofKind(got, 'node').filter((each) => each.node === notebook.id);
expect(sent.length).toBeGreaterThanOrEqual(2);
expect(sent.at(-1)!.settings).toMatchObject({ subnetMask: '255.255.255.0', dnsServer: '192.168.0.100', macAddress: expect.stringMatching(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) });
expect(sent.at(-1)!.settings.ipAddress).toMatch(/^192\.168\.0\.\d+$/);
// Sent when it changes, not every tick.
expect(new Set(sent.map((each) => JSON.stringify(each.settings))).size).toBe(sent.length);
expect(ofKind(got, 'node').filter((each) => each.node === server.id).map((each) => each.settings)).toEqual([
{ ipAddress: '192.168.0.100', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '192.168.0.100', macAddress: expect.any(String) },
]);
// A client that subscribes again gets it again.
const before = got.length;
send({ kind: 'sub', topics: [`node:${server.id}`] });
expect(ofKind(got.slice(before), 'node')).toHaveLength(1);
});
});