From 87c09c6c9dcafa3b094d2ecd1c7228fa05294715 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sun, 11 Oct 2026 18:34:54 +0000 Subject: [PATCH] feat(editor): a modem offers a code or joins one, and says under it how it stands (K4, K5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modem's sheet chooses how it finds its partner: the other modem on its port in this project, as FILIUS' file pairs them; another project's, by offering this modem's code (shown large, with a copy button) or by joining a code typed in as one reads it off the board. In the action mode each modem says under it how it stands — connected, waiting, taken, no second modem on the port, cable missing, on the server only. Accepted in three browsers on a real F, each in a copy of FILIUS' modem example: Ben's notebook pings the computer behind Mia's access point; Rita joins the same code, reads that it is taken, and gets it once Ben stops. FILIUS' modem example runs in the page's own room as well. The .fls export writes an offering modem as Java's server and a joining one as its client; the code has no place there and is named as lost. Co-Authored-By: Claude Opus 5.5 --- packages/editor/browser/modem.spec.ts | 111 +++++++++++++++++ packages/editor/browser/simulation.spec.ts | 10 +- packages/editor/src/Editor.tsx | 25 +++- packages/editor/src/canvas/Canvas.tsx | 24 +++- packages/editor/src/i18n/de.json | 19 +++ packages/editor/src/i18n/en.json | 19 +++ packages/editor/src/remote/SharedProject.tsx | 6 +- packages/editor/src/sheet/DeviceSheet.tsx | 112 ++++++++++++++++-- packages/editor/src/sheet/sheet.css | 9 ++ .../editor/src/simulation/useSimulation.ts | 13 +- packages/fls/src/exportProject.ts | 19 ++- packages/fls/test/exportProject.test.ts | 22 ++++ 12 files changed, 355 insertions(+), 34 deletions(-) create mode 100644 packages/editor/browser/modem.spec.ts diff --git a/packages/editor/browser/modem.spec.ts b/packages/editor/browser/modem.spec.ts new file mode 100644 index 0000000..4c72c50 --- /dev/null +++ b/packages/editor/browser/modem.spec.ts @@ -0,0 +1,111 @@ +// Two projects' networks joined by their modems (FILIUS_MODEM_PLAN K5), on a +// real server F, three people in three browsers, each in a copy of FILIUS' +// modem example. Mia's access point offers a connection and shows its code; +// Ben's accessing modem joins it with the code as typed off the board. Each +// modem says under it how it stands. Ben's notebook pings 192.168.0.11 — +// the computer behind Mia's access point, for the one behind Ben's own is cut +// off: his access point has no partner left on its port. Rita joins the same +// code and reads that it is taken, until Ben stops. + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { onDevice, serveEditor, showDesktop } from './editorPage.js'; +import { startServerF, type ServerF } from './serverF.js'; + +let editor: { readonly base: string; readonly close: () => Promise }; +let f: ServerF; + +test.beforeAll(async () => { + editor = await serveEditor(); + f = await startServerF(editor.base); +}); + +test.afterAll(async () => { + await f?.close(); + await editor?.close(); +}); + +const ACCESS_POINT = 'Modem (Zugangspunkt)'; +const ACCESSING = 'Modem (Zugreifend)'; + +/** A person in their own browser, signed in, in a fresh copy of the modem example. */ +async function person(browser: Browser, name: string): Promise<{ readonly page: Page; readonly node: (name: string) => string }> { + const project = await f.project('java-examples/modem_beispiel.fls'); + const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] }); + await f.signIn(context, project.id, { name }); + const page = await context.newPage(); + await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`); + await page.waitForSelector('svg.filius-canvas'); + return { page, node: project.node }; +} + +const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' }); +const note = (page: Page, id: string) => page.locator(`g.device[data-node="${id}"] text[data-note]`); + +async function choose(page: Page, id: string): Promise { + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + await expect(sheet(page)).toBeVisible(); +} + +async function start(page: Page): Promise { + await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); +} + +test("joins two projects' networks through their modems: a code offered, a code typed, a ping across", async ({ browser }) => { + test.setTimeout(120_000); + const mia = await person(browser, 'Mia'); + const ben = await person(browser, 'Ben'); + const rita = await person(browser, 'Rita'); + + // Mia's access point offers: its sheet shows the code to pass on, and copies it. + await choose(mia.page, mia.node(ACCESS_POINT)); + await sheet(mia.page).getByRole('radio', { name: 'Mit einem anderen Projekt: Verbindung anbieten' }).check(); + const code = (await sheet(mia.page).locator('output.modem-code').textContent())!; + expect(code).toMatch(/^[A-HJ-NP-Z2-9]{3}-[A-HJ-NP-Z2-9]{3}$/); + await sheet(mia.page).getByRole('button', { name: 'Code kopieren' }).click(); + await expect(sheet(mia.page).getByRole('button', { name: 'Kopiert ✓' })).toBeVisible(); + await expect(mia.page.getByRole('alert')).toHaveCount(0); + expect(await mia.page.evaluate(() => navigator.clipboard.readText())).toBe(code); + await mia.page.screenshot({ path: 'test-results/modem-offer.png' }); + + // Ben's accessing modem joins, the code typed as one reads it off the board. + for (const person of [ben, rita]) { + await choose(person.page, person.node(ACCESSING)); + await sheet(person.page).getByRole('radio', { name: 'Mit einem anderen Projekt: mit Code verbinden' }).check(); + const field = sheet(person.page).locator('input[name="modemCode"]'); + await field.fill(code.toLowerCase().replace('-', ' ')); + await field.press('Enter'); + await expect(field).toHaveValue(code); + } + await ben.page.screenshot({ path: 'test-results/modem-join.png' }); + + await start(mia.page); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveText('wartet auf Gegenstelle'); + await expect(note(mia.page, mia.node(ACCESSING))).toHaveText('kein zweites Modem mit diesem Port'); + await start(ben.page); + await expect(note(ben.page, ben.node(ACCESSING))).toHaveText('verbunden'); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveText('verbunden'); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveAttribute('data-note', 'good'); + await mia.page.screenshot({ path: 'test-results/modem-connected.png' }); + + // Ben's notebook pings the computer behind Mia's access point. + const notebook = ben.node('Notebook 0.10'); + await showDesktop(ben.page, notebook); + const desktop = ben.page.locator(`section[data-desktop="${notebook}"]`); + await desktop.locator('[data-program="commandPrompt"]').click(); + const input = desktop.locator('[data-program-window="commandPrompt"] input[name="terminalInput"]'); + await input.fill('ping 192.168.0.11'); + await input.press('Enter'); + await expect(desktop.locator('.terminal-text')).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); + + // Rita wants the same code: taken while Ben has it, hers once he stops. + await start(rita.page); + await expect(note(rita.page, rita.node(ACCESSING))).toHaveText('Code schon belegt'); + await expect(note(rita.page, rita.node(ACCESSING))).toHaveAttribute('data-note', 'trouble'); + await ben.page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click(); + await expect(note(rita.page, rita.node(ACCESSING))).toHaveText('verbunden'); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveText('verbunden'); + // In the design there is nothing to say. + await expect(ben.page.locator('text[data-note]')).toHaveCount(0); +}); diff --git a/packages/editor/browser/simulation.spec.ts b/packages/editor/browser/simulation.spec.ts index 2f43a2f..ffad28b 100644 --- a/packages/editor/browser/simulation.spec.ts +++ b/packages/editor/browser/simulation.spec.ts @@ -477,15 +477,15 @@ test("installs and removes programs as Java's installation does, and a program i expect((await desktopOf(page, id))!.programs).toEqual(['commandPrompt']); }); -test('says so when the simulation fails, and goes back to the design', async ({ page }) => { - // FILIUS' modem example: the core has no modems yet (remote links); a ping to the other side fails it. +test("runs FILIUS' modem example: both modems say they are connected, a ping goes through the pair", async ({ page }) => { await openEditor(page, server.base, 'modem_beispiel.fls'); await start(page); - const { input } = await commandLine(page, 'Notebook 0.10'); + for (const name of ['Modem (Zugreifend)', 'Modem (Zugangspunkt)']) + await expect(page.locator(`g.device[data-node="${await deviceNamed(page, name)}"] text[data-note]`)).toHaveText('verbunden'); + const { input, text } = await commandLine(page, 'Notebook 0.10'); await input.fill('ping 192.168.0.11'); await input.press('Enter'); - await expect(page.getByRole('alert')).toHaveText('Die Simulation ist auf einen Fehler gestoßen und wurde beendet.', { timeout: 20_000 }); - await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design'); + await expect(text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); }); test('speaks English when asked', async ({ page }) => { diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 95a3b1b..f5a1d83 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -31,6 +31,7 @@ // (moving.ts). import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import type { ModemStatus } from '@mdg-filius/protocol'; import type * as Y from 'yjs'; import { addDevice, @@ -51,7 +52,7 @@ import { type TopologyView, } from '@mdg-filius/doc'; import { DOCUMENTATION_NEW_SIZE, type DocumentationItemKind } from '@mdg-filius/core'; -import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js'; +import { Canvas, type CanvasHandle, type DeviceNote, type Selection, type Side } from './canvas/Canvas.js'; import type { Point } from './canvas/viewport.js'; import { ContextMenu, type MenuItem } from './design/ContextMenu.js'; import { DocumentationMenu } from './design/DocumentationMenu.js'; @@ -182,14 +183,32 @@ export interface EditorProps { readonly frozen?: boolean; /** The others in the project and how to tell them this editor's state (on F). */ readonly presence?: Presence; + /** The project on F, whose modems offer codes to other projects; none on the development page. */ + readonly project?: string; } -export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false, presence }: EditorProps) { +/** How a modem's standing reads under it: good news, a wait, or trouble. */ +const MODEM_TONES: Readonly> = { + connected: 'good', + waiting: 'waiting', + taken: 'trouble', + unpaired: 'trouble', + ambiguous: 'trouble', + detached: 'trouble', + unavailable: 'trouble', +}; + +export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false, presence, project }: EditorProps) { const t = useTranslate(); const language = useLanguage(); const view = useTopologyView(doc); const simulation = useSimulation(doc, createSimHost); const simulating = simulation.mode === 'simulation'; + // How each modem stands, under its label, while the simulation runs. + const modemNotes = useMemo( + () => new Map([...simulation.modems].map(([id, status]) => [id, { text: t(`modem.status.${status}`), tone: MODEM_TONES[status] }] as const)), + [simulation.modems, t], + ); const [documenting, setDocumenting] = useState(false); // The simulation comes first: started, it ends the documentation mode (changeMode). const editorMode: EditorMode = simulating ? 'simulation' : documenting ? 'documentation' : 'design'; @@ -516,6 +535,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz ); }} activeLinks={simulation.activeLinks} + notes={modemNotes} onMenu={(target, at) => { if (readOnly && !simulating) return; if (editorMode === 'documentation') { @@ -683,6 +703,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz onHighlight={setHighlightedLink} onClose={() => setSelection(null)} readOnly={readOnly} + {...(project === undefined ? {} : { project })} /> )} diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx index d0aa583..3a5f964 100644 --- a/packages/editor/src/canvas/Canvas.tsx +++ b/packages/editor/src/canvas/Canvas.tsx @@ -107,9 +107,22 @@ export interface CanvasProps { readonly renderDocText?: (item: DocumentationView, frame: ScreenFrame) => React.ReactNode; /** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */ readonly activeLinks?: ReadonlySet; + /** A line under a device's label, by node (how a modem stands while the simulation runs). */ + readonly notes?: ReadonlyMap; } +/** A line under a device's label: its text, and whether it is good news, a wait or trouble. */ +export interface DeviceNote { + readonly text: string; + readonly tone: 'good' | 'waiting' | 'trouble'; +} + +const NOTE_COLORS: Readonly> = { good: 'var(--accent)', waiting: 'var(--text-secondary)', trouble: 'var(--danger)' }; +/** How far the note sits below the label's baseline. */ +const NOTE_LINE = 14; + const NO_LINKS: ReadonlySet = new Set(); +const NO_NOTES: ReadonlyMap = new Map(); const NO_CHOICES: ReadonlyMap = new Map(); const NO_COLORS: readonly string[] = []; @@ -195,6 +208,7 @@ function Device({ selected, pending, chosenBy, + note, }: { readonly node: NodeView; readonly box: DeviceBox; @@ -203,6 +217,7 @@ function Device({ readonly pending: boolean; /** The colours of the others who have chosen it, each a frame further out. */ readonly chosenBy: readonly string[]; + readonly note: DeviceNote | undefined; }) { return ( {/* The whole button takes the pointer, as Java's label does. */} @@ -249,6 +264,11 @@ function Device({ {node.label} + {note === undefined ? null : ( + + {note.text} + + )} ); } @@ -288,6 +308,7 @@ export const Canvas = forwardRef(function Canvas( locked = false, chosenBy = NO_CHOICES, activeLinks = NO_LINKS, + notes = NO_NOTES, onDocFrame, renderDocText, }, @@ -741,6 +762,7 @@ export const Canvas = forwardRef(function Canvas( selected={selection?.kind === 'device' && selection.id === node.id} chosenBy={chosenBy.get(node.id) ?? NO_COLORS} pending={cableFrom?.id === node.id} + note={notes.get(node.id)} /> ))} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index f010100..d441a36 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -331,6 +331,25 @@ "mode.documentation": "Wechselt in den Dokumentationsmodus", "mode.simulation": "Wechselt in den Aktionsmodus", "mode.speed": "Simulationsgeschwindigkeit anpassen", + "modem.code": "Code dieses Modems", + "modem.connection": "Verbindung", + "modem.connection.inProject": "Mit einem Modem in diesem Projekt (gleicher Port)", + "modem.connection.join": "Mit einem anderen Projekt: mit Code verbinden", + "modem.connection.offer": "Mit einem anderen Projekt: Verbindung anbieten", + "modem.copied": "Kopiert ✓", + "modem.copy": "Code kopieren", + "modem.copyFailed": "Der Code ließ sich nicht kopieren.", + "modem.joinHint": "Den Code zeigt das Modem im anderen Projekt. Die Verbindung steht, sobald beide Simulationen laufen.", + "modem.offerHint": "Geben Sie den Code an die andere Gruppe weiter. Ihr Modem verbindet sich, sobald beide Simulationen laufen.", + "modem.partnerCode": "Code des anderen Modems", + "modem.serverOnly": "Verbindungen zu anderen Projekten gibt es nur in Projekten auf dem Server.", + "modem.status.ambiguous": "mehr als zwei Modems mit diesem Port", + "modem.status.connected": "verbunden", + "modem.status.detached": "Kabel fehlt", + "modem.status.taken": "Code schon belegt", + "modem.status.unavailable": "nur auf dem Server", + "modem.status.unpaired": "kein zweites Modem mit diesem Port", + "modem.status.waiting": "wartet auf Gegenstelle", "network.dnsServer": "DNS-Server:", "network.gateway": "Gateway:", "network.ipAddress": "IP-Adresse:", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index cdde8cb..ddcc754 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -331,6 +331,25 @@ "mode.documentation": "Change to Documentation Mode", "mode.simulation": "Change to Simulation Mode", "mode.speed": "Adjust Simulation Velocity", + "modem.code": "Code of this modem", + "modem.connection": "Connection", + "modem.connection.inProject": "With a modem in this project (same port)", + "modem.connection.join": "With another project: connect with a code", + "modem.connection.offer": "With another project: offer a connection", + "modem.copied": "Copied ✓", + "modem.copy": "Copy code", + "modem.copyFailed": "The code could not be copied.", + "modem.joinHint": "The modem in the other project shows the code. The connection stands as soon as both simulations run.", + "modem.offerHint": "Pass the code on to the other group. Your modem connects as soon as both simulations run.", + "modem.partnerCode": "Code of the other modem", + "modem.serverOnly": "Connections to other projects exist only in projects on the server.", + "modem.status.ambiguous": "more than two modems with this port", + "modem.status.connected": "connected", + "modem.status.detached": "cable missing", + "modem.status.taken": "code already taken", + "modem.status.unavailable": "on the server only", + "modem.status.unpaired": "no second modem with this port", + "modem.status.waiting": "waiting for the other side", "network.dnsServer": "DNS server:", "network.gateway": "Gateway:", "network.ipAddress": "IP address:", diff --git a/packages/editor/src/remote/SharedProject.tsx b/packages/editor/src/remote/SharedProject.tsx index cb5e0a6..a592a53 100644 --- a/packages/editor/src/remote/SharedProject.tsx +++ b/packages/editor/src/remote/SharedProject.tsx @@ -26,11 +26,13 @@ type Ready = Extract; /** The editor on the ready document, with the others in the project. */ function ReadyEditor({ + project, state, createSimHost, onPeople, blobs, }: { + readonly project: string; readonly state: Ready; readonly createSimHost: SimHostFactory; readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined; @@ -52,7 +54,7 @@ function ReadyEditor({ const reach = useMemo(() => (files === null || writing ? files : { saveFile: files.saveFile }), [files, writing]); return ( - + ); } @@ -104,7 +106,7 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps) {t('remote.uploadFailed')}

) : null} - + ); } diff --git a/packages/editor/src/sheet/DeviceSheet.tsx b/packages/editor/src/sheet/DeviceSheet.tsx index da6343b..b8524ca 100644 --- a/packages/editor/src/sheet/DeviceSheet.tsx +++ b/packages/editor/src/sheet/DeviceSheet.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import type * as Y from 'yjs'; +import { modemCode, modemCodeText } from '@mdg-filius/core'; import { deviceSheet, setDhcpClient, @@ -14,6 +15,8 @@ import { setHostLabels, setHostWireless, setIpForwarding, + setModemCode, + setModemConnection, setModemPort, setSwitchCloud, setSwitchRetention, @@ -21,6 +24,7 @@ import { type CheckedField, type DeviceSheet as Sheet, type HostSheet, + type ModemConnection, type ModemSheet, type SwitchSheet, type WirelessError, @@ -63,6 +67,8 @@ export interface DeviceSheetProps { readonly onHighlight: (link: string | null) => void; /** Who only reads (on F): everything shown, nothing changed (SheetLock). */ readonly readOnly?: boolean; + /** The project on F (a modem's code is derived from it); none on the development page. */ + readonly project?: string; } /** A page of the sheet that Java opens as a dialog. */ @@ -70,7 +76,7 @@ export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections'; const PAGE_TITLES: Readonly> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' }; -export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false }: DeviceSheetProps) { +export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false, project }: DeviceSheetProps) { const t = useTranslate(); const sheet = useDeviceSheet(doc, id); // The page open, if any; the editor makes a sheet per device, so another device starts at its fields. @@ -116,7 +122,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl ) : sheet.kind === 'remoteLink' ? ( - + ) : sheet.kind === 'router' ? ( @@ -264,19 +270,103 @@ function SwitchFields({ sheet, change }: { readonly sheet: SwitchSheet; readonly ); } -function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) { +const MODEM_CONNECTIONS: readonly ModemConnection[] = ['inProject', 'offer', 'join']; +/** How long the copy button says the code is copied. */ +const COPIED_MILLISECONDS = 2_000; + +/** + * A modem (JModemKonfiguration): its name, and how it finds its partner — + * the other modem on its port in this project, as FILIUS' file pairs them; + * or a modem of another project, which takes the place of Java's IP address + * and port: this one offers a code to pass on, or joins the modem whose code + * is typed in (FILIUS_MODEM_PLAN). The code needs server F; on the + * development page there is none. + */ +function ModemFields({ + sheet, + change, + project, + onMessage, +}: { + readonly sheet: ModemSheet; + readonly change: Change; + readonly project: string | undefined; + readonly onMessage: (message: string) => void; +}) { const t = useTranslate(); + const code = project === undefined ? undefined : modemCodeText(modemCode(project, change.id)); + // The button says so for a moment once the code is copied; a failure goes to the editor's message. + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), COPIED_MILLISECONDS); + return () => clearTimeout(timer); + }, [copied]); + const copy = () => { + if (code === undefined) return; + (navigator.clipboard?.writeText(code) ?? Promise.reject(new Error('no clipboard'))).then( + () => setCopied(true), + () => onMessage(t('modem.copyFailed')), + ); + }; return ( <> - change.run(() => setModemPort(change.doc, change.id, text, change.origin))} - /> +
+ {MODEM_CONNECTIONS.map((connection) => ( + + ))} +
+ {sheet.connection === 'inProject' ? ( + change.run(() => setModemPort(change.doc, change.id, text, change.origin))} + /> + ) : sheet.connection === 'offer' ? ( + code === undefined ? ( +

{t('modem.serverOnly')}

+ ) : ( + <> +
+ {t('modem.code')} + + {code} + +
+
+ +
+

{t('modem.offerHint')}

+ + ) + ) : ( + <> + change.run(() => setModemCode(change.doc, change.id, text, change.origin))} + /> +

{t(project === undefined ? 'modem.serverOnly' : 'modem.joinHint')}

+ + )} ); } diff --git a/packages/editor/src/sheet/sheet.css b/packages/editor/src/sheet/sheet.css index 93c86c9..0bcbdde 100644 --- a/packages/editor/src/sheet/sheet.css +++ b/packages/editor/src/sheet/sheet.css @@ -427,6 +427,15 @@ margin: 8px 0; } +/* A modem's code to pass on: read aloud or copied off the board, so large and spaced. */ +.modem-code { + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 22px; + font-weight: 600; + letter-spacing: 0.12em; + color: var(--text-primary); +} + .sheet-connected, .sheet-note { margin: 8px 0; diff --git a/packages/editor/src/simulation/useSimulation.ts b/packages/editor/src/simulation/useSimulation.ts index 48a3a7e..68455d8 100644 --- a/packages/editor/src/simulation/useSimulation.ts +++ b/packages/editor/src/simulation/useSimulation.ts @@ -1,14 +1,14 @@ // The editor's side of the simulation room (plan §6): its mode and speed, // the cables that carry frames, a computer's network as it runs, the lines // and views of the programs' windows and the actions taken in them, the -// cards' frames for the data exchange window. The room +// cards' frames for the data exchange window, how each modem stands. The room // runs behind a SimHost — on F for everyone in the project (RemoteSimHost), // or in a worker of this page (LocalSimHost) on the development page; the // editor keeps no simulation of its own. import { useCallback, useEffect, useRef, useState } from 'react'; import type * as Y from 'yjs'; -import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol'; +import type { AppKind, DroppedUnit, ModemStatus, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol'; import { LocalSimHost } from '@mdg-filius/sim'; import SimWorker from '@mdg-filius/sim/worker?worker'; import type { Language } from '../i18n/i18n.js'; @@ -52,6 +52,8 @@ export interface Simulation { readonly dropped: readonly DroppedUnit[]; /** Each watched card's frames as the data exchange window shows them, by its port. */ readonly traces: ReadonlyMap; + /** How each modem stands while the simulation runs, by node; empty in the design. */ + readonly modems: ReadonlyMap; /** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */ start(language?: Language): void; stop(): void; @@ -71,6 +73,7 @@ export interface Simulation { } const NO_LINKS: ReadonlySet = new Set(); +const NO_MODEMS: ReadonlyMap = new Map(); export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation { const host = useRef(null); @@ -89,6 +92,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH const [clock, setClock] = useState(0); const [dropped, setDropped] = useState([]); const [traces, setTraces] = useState>(new Map()); + const [modems, setModems] = useState>(NO_MODEMS); useEffect(() => { const made = createHost(doc); @@ -133,6 +137,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH case 'view': setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view)); return; + case 'modems': + setModems(new Map(Object.entries(message.modems))); + return; case 'error': if (message.code === 'engineFailed') setFailed(true); if (message.code === 'readOnly' || message.code === 'frozen') { @@ -217,5 +224,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() }); }, []); - return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, restart, setSpeed, drop, clearCapture, watch, captures, act }; + return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, modems, start, stop, restart, setSpeed, drop, clearCapture, watch, captures, act }; } diff --git a/packages/fls/src/exportProject.ts b/packages/fls/src/exportProject.ts index 91d043c..c290b2a 100644 --- a/packages/fls/src/exportProject.ts +++ b/packages/fls/src/exportProject.ts @@ -232,12 +232,12 @@ function exportNode(node: TopologyNode, project: ProjectToExport, servingModems: }; } -/** The first node of each remote link pair: its modem serves. */ +/** The first node of each remote link pair in the project: its modem serves (a modem of another project serves by its role). */ function firstOfEachPair(nodes: readonly TopologyNode[], project: ProjectToExport): Set { const seen = new Set(); const serving = new Set(); for (const node of nodes) { - if (node.kind !== 'remoteLink') continue; + if (node.kind !== 'remoteLink' || project.remoteLinkConfigurations.get(node.id)?.relay !== undefined) continue; const pair = project.remoteLinkConfigurations.get(node.id)?.pairIdentifier ?? ''; if (seen.has(pair)) continue; seen.add(pair); @@ -255,14 +255,13 @@ function exportSystem(node: TopologyNode, project: ProjectToExport, servingModem const configuration = project.switchConfigurations.get(id) ?? defaultSwitchConfiguration(id); return { ssid: configuration.ssid, retentionTime: configuration.retentionTimeMilliseconds }; } - case 'remoteLink': - return { - modem: { - mode: servingModems.has(id) ? 1 : 2, - port: modemPort(project.remoteLinkConfigurations.get(id)?.pairIdentifier), - ipAddress: 'localhost', - }, - }; + case 'remoteLink': { + const configuration = project.remoteLinkConfigurations.get(id); + // A modem of another project: Java's server offers, its client joins; the code has no place in Java's file. + if (configuration?.relay !== undefined) warn('the connection to another project (its code) is not kept'); + const serves = configuration?.relay === undefined ? servingModems.has(id) : configuration.relay.role === 'offer'; + return { modem: { mode: serves ? 1 : 2, port: modemPort(configuration?.pairIdentifier), ipAddress: 'localhost' } }; + } case 'unsupported': return {}; default: diff --git a/packages/fls/test/exportProject.test.ts b/packages/fls/test/exportProject.test.ts index 0f86782..ef4f281 100644 --- a/packages/fls/test/exportProject.test.ts +++ b/packages/fls/test/exportProject.test.ts @@ -1029,6 +1029,28 @@ describe('exportProject rules', () => { ]); }); + it("writes a modem of another project as Java's server when it offers and as its client when it joins, and says the code is not kept", () => { + const modems = project([node('offer', 'remoteLink', ['o0']), node('join', 'remoteLink', ['j0']), node('a', 'remoteLink', ['a0']), node('b', 'remoteLink', ['b0'])], []); + const { topology, warnings } = exportProject({ + ...modems, + remoteLinkConfigurations: new Map([ + // The joining modem comes first among the modems on port 12345: by its role, not as the first of a pair. + ['join', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true, relay: { role: 'join', code: 'K7F3QX' } }], + ['offer', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true, relay: { role: 'offer' } }], + ['a', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }], + ['b', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }], + ]), + }); + expect(topology.nodes.map((each) => [each.name, each.system.modem?.mode])).toEqual([ + ['offer', 1], + ['join', 2], + // The pair in the project: its first serves, the relay modems on the same port do not count. + ['a', 1], + ['b', 2], + ]); + expect(warnings).toEqual(["node 'offer': the connection to another project (its code) is not kept", "node 'join': the connection to another project (its code) is not kept"]); + }); + it('joins wirelessly only a host with a wireless card and a switch', () => { const { topology } = exportProject( project(