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))}
- />
+
+ {sheet.connection === 'inProject' ? (
+ change.run(() => setModemPort(change.doc, change.id, text, change.origin))}
+ />
+ ) : sheet.connection === 'offer' ? (
+ code === undefined ? (
+
+ >
+ )}
>
);
}
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(