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') {