// What the editor's design tools do to the document: a new device as FILIUS' // palette makes it, a device moved or removed, a cable plugged or pulled — // each a transaction with the person's origin, so that their undo takes back // their own changes and nobody else's (plan §5.1; Scratch's lesson 2). // // A new device is what Java's constructor makes (GUIContainer.neuerKnoten, // measured with tools/java-oracle's NewDeviceRun): a PC or notebook with // 192.168.0.10/24 and an empty file system, a switch with 24 ports, a router // with the interfaces chosen (2 to 8, all 192.168.0.10/24) and its firewall // off, a home router with WAN 42.0.0.10/8 and LAN 192.168.0.10/24, a modem // on port 12345. Java rolls the MAC addresses and a switch's SSID; here they // come from the ids, as for every device without them (stable across saves). // // A cable follows Java's rules (GUIEvents, createConnection): a device's // first free port, a home router's WAN port unless its LAN port is asked // for (Java: the right half of its button), a wireless host only to a // switch, two devices joined once. import * as Y from 'yjs'; import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, firewallConfiguration, interfaceKey, VirtualFileSystem, type Id, type NodeKind, type TopologyNode, } from '@mdg-filius/core'; import { DEFAULT_MODEM_PORT, modemPair, type ImportedProject } from '@mdg-filius/fls'; import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js'; /** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */ export const PALETTE: readonly Exclude[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink']; /** * A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is * closed); its connections dialog removes them down to `fewest` here (Java: * none — see settings.ts). */ export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2, fewest: 1 } as const; export interface NewDevice { readonly kind: Exclude; readonly id: Id; /** Java's name of the kind in the editor's language (Knoten.TYPE: "Rechner", "Computer" …). */ readonly name: string; /** The top left corner of its button. */ readonly x: number; readonly y: number; /** A router's interfaces. */ readonly interfaces?: number; /** Makes the ports' ids. */ readonly newId: () => Id; } const HOST_INTERFACE = { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' } as const; function portLabels(kind: NewDevice['kind'], interfaces: number): string[] { switch (kind) { case 'pc': case 'notebook': return ['eth0']; case 'networkSwitch': return Array.from({ length: 24 }, (_, index) => `sw${index + 1}`); case 'router': return Array.from({ length: interfaces }, (_, index) => `rt${index + 1}`); case 'gateway': return ['wan0', 'lan0']; case 'remoteLink': return ['remote0']; } } /** The new device as a project of its own: Java's constructor's state in the editor's model. */ export function newDeviceProject(device: NewDevice): ImportedProject { const interfaces = Math.min(ROUTER_INTERFACES.maximum, Math.max(ROUTER_INTERFACES.minimum, Math.trunc(device.interfaces ?? ROUTER_INTERFACES.standard))); const node: TopologyNode = { id: device.id, kind: device.kind, displayName: device.name, hostLabelPolicy: 'manual', position: { x: Math.round(device.x), y: Math.round(device.y) }, ports: portLabels(device.kind, interfaces).map((label) => ({ id: device.newId(), label, isOccupied: false })), }; const project = emptyProject(); project.graph.nodes.push(node); const card = (index: number, address: { ipAddress: string; subnetMask: string }) => project.interfaceConfigurations.set(interfaceKey(device.id, node.ports[index]!.id), address); switch (device.kind) { case 'pc': case 'notebook': project.deviceConfigurations.set(device.id, { ...HOST_INTERFACE, defaultGateway: '', dnsServer: '' }); project.virtualFileSystems.set(device.id, new VirtualFileSystem()); break; case 'networkSwitch': project.switchConfigurations.set(device.id, defaultSwitchConfiguration(device.id)); break; case 'router': node.ports.forEach((_, index) => card(index, HOST_INTERFACE)); project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration()); break; case 'gateway': card(0, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' }); card(1, HOST_INTERFACE); project.deviceConfigurations.set(device.id, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0', defaultGateway: '', dnsServer: '' }); project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration()); break; case 'remoteLink': project.remoteLinkConfigurations.set(device.id, { pairIdentifier: modemPair(DEFAULT_MODEM_PORT), latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }); break; } return project; } const nodes = (doc: Y.Doc) => doc.getMap>(ROOTS.nodes); const links = (doc: Y.Doc) => doc.getMap>(ROOTS.links); /** Adds the device after all others. */ export function addDevice(doc: Y.Doc, device: NewDevice, origin: unknown): void { const project = newDeviceProject(device); doc.transact(() => { const order = Math.max(-1, ...[...nodes(doc).values()].map((node) => node.get('order') as number)) + 1; nodes(doc).set(device.id, nodeEntry(project.graph.nodes[0]!, order, project)); }, origin); } /** Moves a device's button to a place (whole pixels, as Java keeps them). */ export function moveDevice(doc: Y.Doc, id: Id, x: number, y: number, origin: unknown): void { const node = nodes(doc).get(id); if (node === undefined) return; doc.transact(() => { node.set('x', Math.round(x)); node.set('y', Math.round(y)); }, origin); } /** Removes a device with its cables, as Java does. */ export function removeDevice(doc: Y.Doc, id: Id, origin: unknown): void { doc.transact(() => { for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId); nodes(doc).delete(id); }, origin); } export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void { doc.transact(() => links(doc).delete(id), origin); } /** Java's "remove all cables" of a device (its context menu). */ export function removeLinksOf(doc: Y.Doc, id: Id, origin: unknown): void { doc.transact(() => { for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId); }, origin); } /** A cable's end: a device, and the port when one was asked for. */ export interface CableEnd { readonly node: Id; readonly port?: Id; } /** * Why a cable cannot be plugged, with Java's message where it has one: * noFreePort guievents_msg1, wanOccupied guievents_msg22, lanOccupied * guievents_msg23, wirelessNotPossible guievents_msg26, alreadyConnected * guievents_msg12. */ export type ConnectError = 'unknownDevice' | 'sameDevice' | 'unknownPort' | 'portTaken' | 'noFreePort' | 'wanOccupied' | 'lanOccupied' | 'wirelessNotPossible' | 'alreadyConnected'; /** The ports cables use, as `node/port`. */ function pluggedPorts(doc: Y.Doc): Set { return new Set([...links(doc).values()].flatMap((link) => [`${link.get('sourceNode')}/${link.get('sourcePort')}`, `${link.get('targetNode')}/${link.get('targetPort')}`])); } /** The port a cable takes at an end, or why there is none. */ export function cablePort(doc: Y.Doc, end: CableEnd): { readonly port: Id } | { readonly error: ConnectError } { const node = nodes(doc).get(end.node); if (node === undefined) return { error: 'unknownDevice' }; const ports = (node.get('ports') as Y.Array>).toArray().map((port) => ({ id: port.get('id') as Id, label: port.get('label') as string })); const plugged = pluggedPorts(doc); const free = (port: Id) => !plugged.has(`${end.node}/${port}`); const kind = node.get('kind') as NodeKind; if (kind === 'gateway') { // WAN unless LAN is asked for; each its own message when taken. const wanted = end.port === undefined ? ports[0] : ports.find((port) => port.id === end.port); if (wanted === undefined) return { error: 'unknownPort' }; if (free(wanted.id)) return { port: wanted.id }; return { error: wanted.label === 'wan0' ? 'wanOccupied' : wanted.label === 'lan0' ? 'lanOccupied' : 'portTaken' }; } if (end.port !== undefined) { if (!ports.some((port) => port.id === end.port)) return { error: 'unknownPort' }; return free(end.port) ? { port: end.port } : { error: 'portTaken' }; } const first = ports.find((port) => free(port.id)); return first === undefined ? { error: 'noFreePort' } : { port: first.id }; } /** A host whose card joins a switch's wireless network. */ function wireless(doc: Y.Doc, id: Id): boolean { const node = nodes(doc).get(id)!; const kind = node.get('kind') as NodeKind; const settings = node.get('settings') as Y.Map>; return (kind === 'pc' || kind === 'notebook') && settings.get('wireless')?.get('isEnabled') === true; } /** Plugs a cable between two devices, from the first to the second, or says why it cannot. */ export function connect(doc: Y.Doc, from: CableEnd, to: CableEnd, linkId: Id, origin: unknown): { readonly link: Id } | { readonly error: ConnectError } { if (!nodes(doc).has(from.node) || !nodes(doc).has(to.node)) return { error: 'unknownDevice' }; if (from.node === to.node) return { error: 'sameDevice' }; const source = cablePort(doc, from); if ('error' in source) return source; const target = cablePort(doc, to); if ('error' in target) return target; const kindOf = (id: Id) => nodes(doc).get(id)!.get('kind') as NodeKind; if ((wireless(doc, from.node) && kindOf(to.node) !== 'networkSwitch') || (wireless(doc, to.node) && kindOf(from.node) !== 'networkSwitch')) return { error: 'wirelessNotPossible' }; const joined = [...links(doc).values()].some((link) => { const ends = [link.get('sourceNode'), link.get('targetNode')]; return ends.includes(from.node) && ends.includes(to.node); }); if (joined) return { error: 'alreadyConnected' }; doc.transact(() => { const link = new Y.Map(); link.set('order', Math.max(-1, ...inOrder(links(doc)).map(([, each]) => each.get('order') as number)) + 1); link.set('sourceNode', from.node); link.set('sourcePort', source.port); link.set('targetNode', to.node); link.set('targetPort', target.port); links(doc).set(linkId, link); }, origin); return { link: linkId }; } /** * A person's undo: their own changes to the devices, cables and * documentation items, nobody else's. Changes within a short while (a drag) * are taken back at once. */ export function createUndoManager(doc: Y.Doc, origin: unknown): Y.UndoManager { return new Y.UndoManager([nodes(doc), links(doc), doc.getMap(ROOTS.docs)], { trackedOrigins: new Set([origin]), captureTimeout: 500 }); }