feat(doc): F4.4a devices and cables in the document, undo per person; NewDeviceRun
A new device is what Java's palette makes, measured with the new oracle NewDeviceRun (every kind and router size saved as FILIUS saves). Devices move to whole pixels and go with their cables; a cable takes the first free port, a home router's WAN port unless its LAN port is asked for, a wireless host only joins a switch, two devices join once, each refusal named with Java's message. Each person's undo takes back their own changes only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// 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 {
|
||||
defaultSwitchConfiguration,
|
||||
firewallConfiguration,
|
||||
interfaceKey,
|
||||
VirtualFileSystem,
|
||||
type Id,
|
||||
type NodeKind,
|
||||
type TopologyNode,
|
||||
} from '@mdg-filius/core';
|
||||
import type { ImportedProject } from '@mdg-filius/fls';
|
||||
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
|
||||
|
||||
/** The kinds of the palette, in Java's order (GUIDesignSidebar). */
|
||||
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink'];
|
||||
|
||||
/** A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is closed). */
|
||||
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const;
|
||||
|
||||
export interface NewDevice {
|
||||
readonly kind: Exclude<NodeKind, 'unsupported'>;
|
||||
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: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true });
|
||||
break;
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
||||
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(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);
|
||||
}
|
||||
|
||||
/** 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<string> {
|
||||
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<Y.Map<unknown>>).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<Y.Map<unknown>>;
|
||||
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<unknown>();
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user