feat: F4.6g5 the switch and NAT tables and "Pakete verwerfen", measured against Java's

ViewersRun takes the rows of Java's viewers from the firmware: a switch's
table, a home router's NAT table, each after its reset, and the frames
"Pakete verwerfen" drops while it is held. The core writes a frame as Java
does and resets the NAT's dynamic entries; the room sends both tables as
views, drops while any client holds the button, and sends the dropped
frames, begun anew with each start, with the clock their times of day are
written from; the editor opens a table with a tap on a switch or home
router and holds "Pakete verwerfen" in its toolbar, which now wraps on a
phone. This completes F4.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:13:48 +00:00
co-authored by Claude Opus 5.5
parent 5224e1a28b
commit 6809a71300
31 changed files with 1370 additions and 31 deletions
+62 -3
View File
@@ -52,6 +52,8 @@ import {
gnutellaPeerCountOfText,
gnutellaResultRow,
interfaceKey,
javaFrameText,
natViewRow,
IP_ADDRESS_PATTERN,
portRuleSender,
stableGeneratedMacAddress,
@@ -80,6 +82,8 @@ import {
type EchoServerView,
type FirewallView,
type GnutellaView,
type NatView,
type SatView,
type SimpleClientView,
type TraceFrame,
type WebServerView,
@@ -99,6 +103,8 @@ export interface RoomOptions {
readonly seed: () => number;
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
readonly writeBackMilliseconds?: number;
/** The time of day, in milliseconds since 1970, for the viewers' times (Date.now by default). */
readonly epoch?: () => number;
}
interface Client {
@@ -106,6 +112,8 @@ interface Client {
readonly topics: Set<Topic>;
/** Per card, the number of the last frame sent. */
readonly traceSent: Map<Id, number>;
/** The number of the last dropped frame sent; none before the first message (or after a start). */
droppedSent: number | undefined;
/** 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). */
@@ -133,6 +141,8 @@ export class SimulationRoom {
/** Per card, how many frames it held at the last tick. */
private framesSeen = new Map<Id, number>();
private lastActivity: string[] = [];
/** The clients holding "Pakete verwerfen": every cable drops while there is one. */
private readonly dropping = new Set<Client>();
/** Each computer's command line window, kept over stops. */
private readonly terminalWindows = new Map<Id, TerminalWindow>();
/** Each computer's browser window, kept over stops. */
@@ -313,13 +323,15 @@ 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(), nodeSent: new Map(), viewSent: new Map() });
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), droppedSent: undefined, appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
send(this.state());
return id;
}
disconnect(client: number): void {
const gone = this.clients.get(client);
this.clients.delete(client);
if (gone !== undefined && this.dropping.delete(gone)) this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0);
}
/** How many clients the room has (F stops a room nobody watches). */
@@ -350,6 +362,7 @@ export class SimulationRoom {
for (const topic of input.topics) {
client.topics.add(topic);
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
if (topic === 'dropped') client.droppedSent = undefined;
if (topic.startsWith('app:')) {
client.appSent.delete(topic);
client.viewSent.delete(topic);
@@ -367,6 +380,15 @@ export class SimulationRoom {
case 'stop':
this.stop();
return;
case 'drop':
if (this.mode !== 'simulation') {
client.send(message({ kind: 'error', code: 'notRunning' }));
return;
}
if (input.on) this.dropping.add(client);
else this.dropping.delete(client);
this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0);
return;
case 'speed':
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
this.engine.setSimulationSpeed(this.speed);
@@ -384,7 +406,8 @@ export class SimulationRoom {
}
private state(): HostMessage {
return message({ kind: 'state', mode: this.mode, speed: this.speed, t: this.engine.state.currentTimeMilliseconds });
const t = this.engine.state.currentTimeMilliseconds;
return message({ kind: 'state', mode: this.mode, speed: this.speed, t, clock: (this.options.epoch ?? Date.now)() - t });
}
private broadcast(output: HostMessage): void {
@@ -398,6 +421,9 @@ export class SimulationRoom {
this.engine.setSimulationSpeed(this.speed);
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
// Java's SimulationControl begins its list of dropped frames anew with each simulation.
this.engine.capture.clearDropped();
for (const client of this.clients.values()) client.droppedSent = undefined;
this.software = this.documentSoftware();
this.syncWindows();
this.mode = 'simulation';
@@ -412,6 +438,7 @@ export class SimulationRoom {
// A failure already stopped the engine and went back to the design.
if (!this.guarded(() => this.writeBack())) return;
this.engine.handle({ type: 'stop' });
this.dropping.clear();
this.mode = 'design';
this.lastWall = undefined;
this.broadcast(this.state());
@@ -477,6 +504,9 @@ export class SimulationRoom {
return this.actInFirewall(input);
case 'gnutella':
return this.actInGnutella(input);
case 'sat':
case 'nat':
return this.actInViewer(input);
default:
return 'unknownAction';
}
@@ -512,6 +542,19 @@ export class SimulationRoom {
}
}
/** A switch's or home router's viewer: its table reset (SatViewer, NatViewer: "Tabelle löschen", "Tabelle zurücksetzen"). */
private actInViewer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
if (this.nodeKind(input.node) !== (input.app === 'sat' ? 'networkSwitch' : 'gateway')) return 'unknownApp';
if (input.action !== 'reset') return 'unknownAction';
if (input.app === 'sat') this.engine.clearSwitchSat(input.node);
else this.engine.clearDynamicNatEntries(input.node);
return undefined;
}
private nodeKind(node: Id): string | undefined {
return this.engine.state.topologySnapshot.nodes.find((each) => each.id === node)?.kind;
}
/** The Gnutella window: "Netz beitreten" with the address, "Netz verlassen", "Suchen" with the words, "Stopp!", "Liste leeren", "Herunterladen" of the row chosen, the number of peers saved. */
private actInGnutella(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const peer = this.engine.gnutella(input.node);
@@ -762,6 +805,16 @@ export class SimulationRoom {
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
return view;
}
case 'sat': {
if (this.nodeKind(node) !== 'networkSwitch') return null;
const view: SatView = { rows: this.engine.switchSatEntries(node).map((entry) => ({ macAddress: entry.macAddress, port: entry.portNumber, updatedAtMilliseconds: entry.updatedAtMilliseconds })) };
return view;
}
case 'nat': {
if (this.nodeKind(node) !== 'gateway') return null;
const view: NatView = { rows: this.engine.natEntries(node).map(natViewRow) };
return view;
}
case 'gnutella': {
const peer = this.engine.gnutella(node);
if (peer === undefined) return null;
@@ -845,6 +898,12 @@ 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 === 'dropped') {
const reset = client.droppedSent === undefined;
const frames = this.engine.capture.droppedFrames().filter((each) => each.number > (client.droppedSent ?? 0));
if (frames.length === 0 && !reset) continue;
client.droppedSent = frames.at(-1)?.number ?? client.droppedSent ?? 0;
client.send(message({ kind: 'dropped', units: frames.map((each) => ({ number: each.number, timeMilliseconds: each.timeMilliseconds, text: javaFrameText(each.frame) })), reset }));
} else if (topic.startsWith('node:')) {
const node = topic.slice('node:'.length);
const settings = this.nodeSettings(node);
@@ -871,7 +930,7 @@ export class SimulationRoom {
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
}
// The browser's lines are its view; the DNS server's and Gnutella's windows have none.
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella') continue;
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella' || app === 'sat' || app === 'nat') continue;
}
const sent = client.appSent.get(topic);
if (app === 'terminal') {
+7 -3
View File
@@ -29,7 +29,7 @@ function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
/** A room on a clock the test moves, and a client that keeps what it gets. */
function setUp(doc = exampleDoc()) {
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED, epoch: () => 1_700_000_000_000 + now });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const project = readProject(doc);
@@ -58,13 +58,17 @@ const ofKind = <K extends HostMessage['kind']>(got: readonly HostMessage[], kind
describe('a simulation room', () => {
it('tells a new client its state, and every client a change', () => {
const { room, got, send } = setUp();
const { room, got, send, run } = setUp();
const other: HostMessage[] = [];
room.connect((message) => other.push(message));
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]);
// The simulation's time 0 stands for the time of day then (the epoch here follows the room's clock).
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 1_700_001_000_000 }]);
send({ kind: 'start' });
run(3_000);
send({ kind: 'speed', value: 250 });
send({ kind: 'stop' });
// The time of day of the simulation's time 0 stays while it runs.
expect(new Set(ofKind(got, 'state').map((state) => state.clock))).toEqual(new Set([1_700_001_000_000]));
for (const each of [got, other]) {
expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([
['design', 90],
+166
View File
@@ -0,0 +1,166 @@
// The viewers of a running simulation in the room, on FILIUS' example
// nat_with_mapped_port: a switch's table and a home router's NAT table as
// views (their reset an action), and "Pakete verwerfen": every cable drops
// while any client holds it, and the dropped frames go to who watches them,
// begun anew with each simulation. The tables' rows against Java's are
// packages/core/test/viewers.test.ts.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { readProject, setSoftware, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import type { DroppedUnit, HostMessage, NatView, SatView } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus/java-examples');
function setUp() {
let counter = 0;
const doc = new Y.Doc();
writeProject(
doc,
openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'nat_with_mapped_port.fls'))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`),
);
const project = readProject(doc);
const idOf = (name: string) => project.graph.nodes.find((node) => node.displayName === name)!.id;
const gateway = [...project.portForwardingRowsByNodeId.keys()][0]!;
// The switch the web server behind the home router hangs on.
const webServer = idOf('Webserver - 192.168.0.10');
const link = project.graph.links.find((each) => each.sourceNodeId === webServer || each.targetNodeId === webServer)!;
const lanSwitch = link.sourceNodeId === webServer ? link.targetNodeId : link.sourceNodeId;
setSoftware(doc, webServer, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
// The web server's card with an address in small letters (a file may hold one so): the switch's table writes it in capitals.
const webPort = (doc.getMap<Y.Map<unknown>>('nodes').get(webServer)!.get('ports') as Y.Array<Y.Map<unknown>>).get(0);
webPort.set('mac', 'aa:bb:cc:00:11:22');
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 });
const views = new Map<string, unknown>();
const dropped: DroppedUnit[] = [];
const resets: number[] = [];
const messages: HostMessage[] = [];
const errors: string[] = [];
const connect = () =>
room.connect((message: HostMessage) => {
messages.push(message);
if (message.kind === 'view') views.set(`${message.node}/${message.app}`, message.view);
if (message.kind === 'dropped') {
if (message.reset) {
resets.push(message.units.length);
dropped.length = 0;
}
dropped.push(...message.units);
}
if (message.kind === 'error') errors.push(message.code);
});
const client = connect();
const send = (fields: object, who = client) => room.receive(who, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
const terminal = (node: string, line: string) => send({ kind: 'input', node, app: 'terminal', action: 'enter', args: { line }, reqId: line });
return { room, idOf, gateway, lanSwitch, webServer, views, dropped, resets, messages, errors, connect, send, run, terminal };
}
describe('the viewers in the room', () => {
it("shows a switch's table and a home router's NAT table, and resets them", () => {
const { gateway, lanSwitch, webServer, views, messages, errors, send, run, terminal } = setUp();
send({ kind: 'sub', topics: [`app:${lanSwitch}/sat`, `app:${gateway}/nat`, `app:${webServer}/sat`, `app:${lanSwitch}/nat`] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
const sat = () => views.get(`${lanSwitch}/sat`) as SatView;
const nat = () => views.get(`${gateway}/nat`) as NatView;
// The port forwarding's entry, before anything went through.
expect(nat()).toEqual({ rows: [{ cells: ['TCP', '80', '*', '192.168.0.10', '80'] }] });
expect(sat()).toEqual({ rows: [] });
// No switch, no home router: no table.
expect([views.get(`${webServer}/sat`), views.get(`${lanSwitch}/nat`)]).toEqual([null, null]);
terminal(webServer, 'ping 141.99.4.10');
run(10_000);
expect(sat().rows.length).toBeGreaterThanOrEqual(2);
expect(sat().rows.every((row) => Number.isInteger(row.port) && row.port >= 1 && row.updatedAtMilliseconds > 0)).toBe(true);
expect(sat().rows.map((row) => row.macAddress)).toContain('AA:BB:CC:00:11:22');
expect(nat().rows.map((row) => row.cells.slice(0, 1).concat(row.cells.slice(2, 4)))).toEqual([
['TCP', '*', '192.168.0.10'],
['ICMP', '141.99.4.10', '192.168.0.10'],
]);
expect(nat().rows[1]!.updatedAtMilliseconds).toBeGreaterThan(0);
send({ kind: 'input', node: lanSwitch, app: 'sat', action: 'reset', args: {}, reqId: 'sat' });
send({ kind: 'input', node: gateway, app: 'nat', action: 'reset', args: {}, reqId: 'nat' });
expect(sat()).toEqual({ rows: [] });
expect(nat()).toEqual({ rows: [{ cells: ['TCP', '80', '*', '192.168.0.10', '80'] }] });
// What the viewers never hand over.
send({ kind: 'input', node: webServer, app: 'sat', action: 'reset', args: {}, reqId: 'a' });
send({ kind: 'input', node: lanSwitch, app: 'nat', action: 'reset', args: {}, reqId: 'b' });
send({ kind: 'input', node: lanSwitch, app: 'sat', action: 'sort', args: {}, reqId: 'c' });
expect(errors).toEqual(['unknownApp', 'unknownApp', 'unknownAction']);
// The viewers have no lines: none are sent.
expect(messages.filter((message) => message.kind === 'app')).toEqual([]);
});
it('drops while any client holds "Pakete verwerfen", and lists what it dropped anew with each simulation', () => {
const { webServer, dropped, resets, messages, errors, connect, send, run, terminal, room } = setUp();
send({ kind: 'sub', topics: ['dropped'] });
// Not while the design is shown.
send({ kind: 'drop', on: true });
expect(errors).toEqual(['notRunning']);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped).toEqual([]);
// Nothing dropped, nothing sent after the subscription's (empty) list.
expect(messages.filter((message) => message.kind === 'dropped')).toHaveLength(2);
const other = connect();
send({ kind: 'drop', on: true });
send({ kind: 'drop', on: true }, other);
send({ kind: 'drop', on: false });
// The other still holds it: the ping is dropped.
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped.length).toBeGreaterThan(0);
expect(dropped[0]!.text).toMatch(/^\[src=[0-9A-F:]+, dest=[0-9A-F:]+, type=0x8(00|06) \| \[/i);
expect(dropped.map((unit) => unit.number)).toEqual(dropped.map((_, index) => index + 1));
const count = dropped.length;
// Subscribed anew, the list comes whole.
send({ kind: 'unsub', topics: ['dropped'] });
send({ kind: 'sub', topics: ['dropped'] });
expect([resets.at(-1), dropped.length]).toEqual([count, count]);
// The other leaves: nothing is dropped any more.
room.disconnect(other);
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped.length).toBe(count);
// A new simulation begins the list anew.
send({ kind: 'stop' });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(resets.at(-1)).toBe(0);
expect(dropped).toEqual([]);
expect(errors).toEqual(['notRunning']);
});
it('forgets who held "Pakete verwerfen" when the simulation stops', () => {
const { webServer, dropped, connect, send, run, terminal } = setUp();
const other = connect();
send({ kind: 'sub', topics: ['dropped'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
// Held through the stop: the letting go comes in the design, and is refused there.
send({ kind: 'drop', on: true }, other);
send({ kind: 'stop' });
send({ kind: 'drop', on: false }, other);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'drop', on: true });
send({ kind: 'drop', on: false });
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped).toEqual([]);
});
});