feat: F5.3a the simulation rooms on server F
One room per project for everyone in it, held by a few worker_threads, each room with a replica of the document that Hocuspocus' direct connection keeps open: changes go in, what the room writes comes back. /sim?project=... signs in as /sync does and says refusals and partings as the socket's closing; only a write grant changes the room. The state names who started the simulation. After the last person the room stays 60 s, then stops, writes back and ends. Freezing stops a running simulation and refuses to start one, deleting ends the room without writing back, revoking lets the person go from the room too; a failed thread ends its rooms, stopping the service stops them all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,8 @@ export interface RoomOptions {
|
||||
|
||||
interface Client {
|
||||
readonly send: (message: HostMessage) => void;
|
||||
/** Who it is, on F; none in a room of one's own page. */
|
||||
readonly name: string | undefined;
|
||||
readonly topics: Set<Topic>;
|
||||
/** Per card, the number of the last frame sent. */
|
||||
readonly traceSent: Map<Id, number>;
|
||||
@@ -149,6 +151,8 @@ export class SimulationRoom {
|
||||
private readonly browserWindows = new Map<Id, BrowserWindow>();
|
||||
/** The language of the last start (the pages the devices write themselves). */
|
||||
private language: PageLanguage | undefined;
|
||||
/** Who started the simulation that runs. */
|
||||
private startedBy: string | undefined;
|
||||
/** Each node's file system and its revision when it was last written back. */
|
||||
private writtenFiles = new Map<Id, { readonly files: object; readonly revision: number }>();
|
||||
/** The wall clock's time of the last write-back. */
|
||||
@@ -320,10 +324,10 @@ export class SimulationRoom {
|
||||
}
|
||||
}
|
||||
|
||||
/** A new client; it gets the room's state at once. */
|
||||
connect(send: (message: HostMessage) => void): number {
|
||||
/** A new client, by name on F; it gets the room's state at once. */
|
||||
connect(send: (message: HostMessage) => void, name?: string): number {
|
||||
const id = this.nextClient++;
|
||||
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), droppedSent: undefined, appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
|
||||
this.clients.set(id, { send, name, topics: new Set(), traceSent: new Map(), droppedSent: undefined, appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
|
||||
send(this.state());
|
||||
return id;
|
||||
}
|
||||
@@ -375,7 +379,7 @@ export class SimulationRoom {
|
||||
for (const topic of input.topics) client.topics.delete(topic);
|
||||
return;
|
||||
case 'start':
|
||||
this.start(input.language);
|
||||
this.start(input.language, client.name);
|
||||
return;
|
||||
case 'stop':
|
||||
this.stop();
|
||||
@@ -422,14 +426,14 @@ export class SimulationRoom {
|
||||
|
||||
private state(): HostMessage {
|
||||
const t = this.engine.state.currentTimeMilliseconds;
|
||||
return message({ kind: 'state', mode: this.mode, speed: this.speed, t, clock: (this.options.epoch ?? Date.now)() - t });
|
||||
return message({ kind: 'state', mode: this.mode, speed: this.speed, t, clock: (this.options.epoch ?? Date.now)() - t, ...(this.startedBy === undefined ? {} : { startedBy: this.startedBy }) });
|
||||
}
|
||||
|
||||
private broadcast(output: HostMessage): void {
|
||||
for (const client of this.clients.values()) client.send(output);
|
||||
}
|
||||
|
||||
start(language?: PageLanguage): void {
|
||||
start(language?: PageLanguage, startedBy?: string): void {
|
||||
if (this.mode === 'simulation') return;
|
||||
this.language = language;
|
||||
const snapshot = this.snapshot();
|
||||
@@ -442,6 +446,7 @@ export class SimulationRoom {
|
||||
this.software = this.documentSoftware();
|
||||
this.syncWindows();
|
||||
this.mode = 'simulation';
|
||||
this.startedBy = startedBy;
|
||||
this.lastWall = this.options.now();
|
||||
this.lastWriteBack = this.lastWall;
|
||||
this.broadcast(this.state());
|
||||
@@ -455,6 +460,7 @@ export class SimulationRoom {
|
||||
this.engine.handle({ type: 'stop' });
|
||||
this.dropping.clear();
|
||||
this.mode = 'design';
|
||||
this.startedBy = undefined;
|
||||
this.lastWall = undefined;
|
||||
this.broadcast(this.state());
|
||||
this.flush([...this.clients.values()], false);
|
||||
@@ -487,6 +493,7 @@ export class SimulationRoom {
|
||||
this.broadcast(message({ kind: 'error', code: 'engineFailed', detail: error instanceof Error ? error.message : String(error) }));
|
||||
if (this.engine.state.phase === 'running') this.engine.handle({ type: 'stop' });
|
||||
this.mode = 'design';
|
||||
this.startedBy = undefined;
|
||||
this.lastWall = undefined;
|
||||
this.broadcast(this.state());
|
||||
this.flush([...this.clients.values()], false);
|
||||
|
||||
@@ -79,6 +79,39 @@ describe('a simulation room', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('names who started the simulation while it runs, and no one in a room of one page', () => {
|
||||
const { room, got, send, run } = setUp();
|
||||
const mia: HostMessage[] = [];
|
||||
const miaId = room.connect((message) => mia.push(message), 'Mia');
|
||||
room.receive(miaId, { v: 1, kind: 'start' });
|
||||
run(400);
|
||||
const late: HostMessage[] = [];
|
||||
room.connect((message) => late.push(message), 'Ben');
|
||||
expect(ofKind(late, 'state').map((state) => state.startedBy)).toEqual(['Mia']);
|
||||
send({ kind: 'speed', value: 50 });
|
||||
send({ kind: 'stop' });
|
||||
expect(ofKind(mia, 'state').map(({ mode, startedBy }) => [mode, startedBy])).toEqual([
|
||||
['design', undefined],
|
||||
['simulation', 'Mia'],
|
||||
['simulation', 'Mia'],
|
||||
['design', undefined],
|
||||
]);
|
||||
// Started by the client without a name, the room names no one.
|
||||
send({ kind: 'start' });
|
||||
expect(ofKind(got, 'state').at(-1)).not.toHaveProperty('startedBy');
|
||||
});
|
||||
|
||||
it('forgets who started it when the core fails', () => {
|
||||
const { room, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
|
||||
const mia: HostMessage[] = [];
|
||||
const miaId = room.connect((message) => mia.push(message), 'Mia');
|
||||
room.receive(miaId, { v: 1, kind: 'start' });
|
||||
room.receive(miaId, { v: 1, kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
|
||||
run(10_000);
|
||||
expect(ofKind(mia, 'state').at(-1)).toMatchObject({ mode: 'design' });
|
||||
expect(ofKind(mia, 'state').at(-1)).not.toHaveProperty('startedBy');
|
||||
});
|
||||
|
||||
it("sends a card's frames and a terminal's lines as the engine has them", () => {
|
||||
const { got, send, node, run } = setUp();
|
||||
const notebook = node('Notebook 1.10');
|
||||
|
||||
Reference in New Issue
Block a user