@adriansteffan/reactive 0.0.43 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/README.md +106 -3
  3. package/dist/{mod-D6W3wq3h.js → mod-D6DlS3ur.js} +6482 -6114
  4. package/dist/mod.d.ts +54 -2
  5. package/dist/reactive.es.js +42 -33
  6. package/dist/reactive.umd.js +38 -36
  7. package/dist/style.css +1 -1
  8. package/dist/{web-B1hJOwit.js → web-D7VcCd-t.js} +1 -1
  9. package/dist/{web-BYSmfdtR.js → web-o3I0sgwu.js} +1 -1
  10. package/package.json +1 -1
  11. package/src/components/canvasblock.tsx +112 -70
  12. package/src/components/checkdevice.tsx +15 -0
  13. package/src/components/enterfullscreen.tsx +5 -3
  14. package/src/components/exitfullscreen.tsx +4 -1
  15. package/src/components/experimentprovider.tsx +7 -2
  16. package/src/components/experimentrunner.tsx +66 -52
  17. package/src/components/microphonecheck.tsx +3 -0
  18. package/src/components/mobilefilepermission.tsx +3 -0
  19. package/src/components/plaininput.tsx +17 -0
  20. package/src/components/prolificending.tsx +3 -0
  21. package/src/components/quest.tsx +57 -0
  22. package/src/components/storeui.tsx +15 -11
  23. package/src/components/text.tsx +11 -0
  24. package/src/components/upload.tsx +56 -271
  25. package/src/mod.tsx +1 -0
  26. package/src/utils/bytecode.ts +50 -0
  27. package/src/utils/simulation.ts +268 -0
  28. package/src/utils/upload.ts +299 -0
  29. package/template/README.md +59 -0
  30. package/template/backend/src/backend.ts +1 -0
  31. package/template/package.json +2 -0
  32. package/template/simulate.ts +15 -0
  33. package/template/src/Experiment.tsx +58 -5
  34. package/template/src/main.tsx +1 -1
  35. package/template/tsconfig.json +2 -3
  36. package/tsconfig.json +1 -0
  37. package/vite.config.ts +1 -1
@@ -0,0 +1,268 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import {
4
+ compileTimeline,
5
+ advanceToNextContent,
6
+ applyMetadata,
7
+ RefinedTrialData,
8
+ ComponentResultData,
9
+ TimelineItem,
10
+ Store,
11
+ } from './bytecode';
12
+
13
+
14
+ export type ParticipantState = Record<string, any>;
15
+
16
+ export type SimulatorResult = {
17
+ responseData: any;
18
+ participantState: ParticipantState;
19
+ storeUpdates?: Record<string, any>;
20
+ duration?: number;
21
+ };
22
+
23
+ export type SimulateFunction = (
24
+ trialProps: Record<string, any>,
25
+ experimentState: {
26
+ data: RefinedTrialData[];
27
+ store: Store;
28
+ },
29
+ simulators: Record<string, any>,
30
+ participant: ParticipantState,
31
+ ) => SimulatorResult | Promise<SimulatorResult>;
32
+
33
+
34
+ interface ComponentSimulation {
35
+ simulate: SimulateFunction;
36
+ defaultSimulators: Record<string, any>;
37
+ }
38
+
39
+ const simulationRegistry: Record<string, ComponentSimulation> = {};
40
+
41
+ export function registerSimulation(
42
+ type: string,
43
+ simulate: SimulateFunction,
44
+ defaultSimulators: Record<string, any>,
45
+ ) {
46
+ simulationRegistry[type] = { simulate, defaultSimulators };
47
+ }
48
+
49
+ export function getSimulation(type: string): ComponentSimulation | undefined {
50
+ return simulationRegistry[type];
51
+ }
52
+
53
+ export const noopSimulate: SimulateFunction = (_trialProps, _experimentState, _simulators, participant) => ({
54
+ responseData: {},
55
+ participantState: participant,
56
+ });
57
+
58
+ export function resolveSimulation(content: any, data: RefinedTrialData[], store: Store) {
59
+ const trialProps =
60
+ typeof content.props === 'function' ? content.props(data, store) : content.props || {};
61
+ const registration = simulationRegistry[content.type];
62
+ const simulateFn = typeof content.simulate === 'function' ? content.simulate : registration?.simulate;
63
+
64
+ if (!simulateFn) {
65
+ throw new Error(`No simulation registered for trial type '${content.type}' (name: '${content.name ?? 'unnamed'}'). Register one with registerSimulation() or add a simulate function to the timeline item.`);
66
+ }
67
+
68
+ const simulators = { ...registration?.defaultSimulators, ...content.simulators };
69
+ return { trialProps, simulateFn, simulators };
70
+ }
71
+
72
+ let _backendUrl = 'http://localhost:8001/backend';
73
+ export function setBackendUrl(url: string) { _backendUrl = url; }
74
+ export function getBackendUrl() { return _backendUrl; }
75
+
76
+ let _initialParticipant: ParticipantState | undefined;
77
+ export function getInitialParticipant() { return _initialParticipant; }
78
+
79
+
80
+ export async function simulateParticipant(
81
+ timeline: TimelineItem[],
82
+ participant: ParticipantState,
83
+ ): Promise<RefinedTrialData[]> {
84
+ _initialParticipant = { ...participant };
85
+ let currentParticipantState = { ...participant };
86
+ const bytecode = compileTimeline(timeline);
87
+ let store: Store = {};
88
+ const data: RefinedTrialData[] = [
89
+ {
90
+ index: -1,
91
+ trialNumber: -1,
92
+ start: 0,
93
+ end: 0,
94
+ duration: 0,
95
+ type: '',
96
+ name: '',
97
+ responseData: { userAgent: 'simulated', params: {} },
98
+ },
99
+ ];
100
+
101
+ const getStore = () => store;
102
+ const getData = () => data;
103
+ const onUpdateStore = (s: Store) => { store = s; };
104
+
105
+ let trialNumber = 0;
106
+ let currentTime = 0;
107
+
108
+ let pointer = advanceToNextContent(bytecode, 0, getStore, getData, onUpdateStore);
109
+
110
+ while (pointer < bytecode.instructions.length) {
111
+ const content = (bytecode.instructions[pointer] as any).content;
112
+ if (typeof content !== 'object' || content === null || typeof content.type !== 'string') {
113
+ pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
114
+ continue;
115
+ }
116
+
117
+ const { trialProps, simulateFn, simulators } = resolveSimulation(content, data, store);
118
+
119
+ const result = await simulateFn(
120
+ trialProps,
121
+ { data, store },
122
+ simulators,
123
+ currentParticipantState,
124
+ );
125
+ currentParticipantState = result.participantState;
126
+ if (result.storeUpdates) store = { ...store, ...result.storeUpdates };
127
+
128
+ trialNumber++;
129
+ const duration = result.duration ?? 0;
130
+ const startTime = currentTime;
131
+ currentTime += duration;
132
+
133
+ let trialData: ComponentResultData = {
134
+ index: pointer,
135
+ trialNumber,
136
+ start: startTime,
137
+ end: currentTime,
138
+ duration,
139
+ type: content.type,
140
+ name: content.name ?? '',
141
+ responseData: result.responseData,
142
+ };
143
+
144
+ data.push(applyMetadata(trialData, content, data, store));
145
+ pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
146
+ }
147
+
148
+ return data;
149
+ }
150
+
151
+
152
+ export interface RunSimulationConfig {
153
+ participants: ParticipantState[] | { generator: (index: number) => ParticipantState; count: number };
154
+ backendPort?: number;
155
+ concurrency?: number;
156
+ }
157
+
158
+ export async function orchestrateSimulation(config: RunSimulationConfig, scriptPath: string): Promise<void> {
159
+ const { spawn } = await import('child_process');
160
+
161
+ const port = config.backendPort ?? 8001;
162
+ const backendUrl = `http://localhost:${port}/backend`;
163
+
164
+ const participantCount = Array.isArray(config.participants)
165
+ ? config.participants.length
166
+ : config.participants.count;
167
+
168
+ const backend = spawn('npx', ['tsx', 'src/backend.ts'], {
169
+ cwd: './backend',
170
+ stdio: ['ignore', 'pipe', 'pipe'],
171
+ });
172
+
173
+ // Wait for backend to signal readiness before spawning workers
174
+ await new Promise<void>((resolve, reject) => {
175
+ backend.on('error', reject);
176
+
177
+ backend.stdout?.on('data', (data: any) => {
178
+ const msg = data.toString();
179
+ if (msg.includes('REACTIVE_BACKEND_READY')) {
180
+ resolve();
181
+ }
182
+ });
183
+
184
+ backend.stderr?.on('data', (data: any) => {
185
+ console.error(`[backend] ${data.toString().trim()}`);
186
+ });
187
+
188
+ setTimeout(() => reject(new Error('Backend did not start within 10 seconds')), 10000);
189
+ });
190
+
191
+ console.log(`Backend running on port ${port}`);
192
+
193
+ try {
194
+ const concurrency = config.concurrency ?? (await import('os')).cpus().length;
195
+ console.log(`Simulating ${participantCount} participants (concurrency: ${concurrency})...`);
196
+
197
+ let completed = 0;
198
+ const errors: { index: number; error: string }[] = [];
199
+
200
+ const writeProgress = () => {
201
+ const pct = Math.min(100, Math.round((completed / participantCount) * 100));
202
+ const filled = Math.floor(pct / 2);
203
+ const bar = '█'.repeat(filled) + '░'.repeat(50 - filled);
204
+ process.stdout.write(`\r ${bar} ${completed}/${participantCount} (${pct}%)`);
205
+ };
206
+
207
+ const spawnWorker = (i: number) => new Promise<void>((resolve) => {
208
+ let stderr = '';
209
+ const worker = spawn('npx', ['tsx', scriptPath], {
210
+ env: {
211
+ ...process.env,
212
+ _REACTIVE_WORKER_INDEX: String(i),
213
+ _REACTIVE_BACKEND_URL: backendUrl,
214
+ },
215
+ stdio: ['ignore', 'pipe', 'pipe'],
216
+ });
217
+
218
+ worker.stderr?.on('data', (data: any) => { stderr += data.toString(); });
219
+
220
+ worker.on('close', (code: number) => {
221
+ if (code !== 0) {
222
+ errors.push({ index: i, error: stderr.trim() || `exited with code ${code}` });
223
+ }
224
+ completed++;
225
+ writeProgress();
226
+ resolve();
227
+ });
228
+ worker.on('error', (err: Error) => {
229
+ errors.push({ index: i, error: err.message });
230
+ completed++;
231
+ writeProgress();
232
+ resolve();
233
+ });
234
+ });
235
+
236
+ writeProgress();
237
+
238
+ // Worker pool: always keep `concurrency` workers running
239
+ let nextIndex = 0;
240
+ await new Promise<void>((resolveAll) => {
241
+ const startNext = () => {
242
+ if (nextIndex >= participantCount) {
243
+ if (completed >= participantCount) resolveAll();
244
+ return;
245
+ }
246
+ const i = nextIndex++;
247
+ spawnWorker(i).then(startNext);
248
+ };
249
+ for (let j = 0; j < Math.min(concurrency, participantCount); j++) {
250
+ startNext();
251
+ }
252
+ });
253
+
254
+ process.stdout.write('\n');
255
+
256
+ if (errors.length > 0) {
257
+ console.error(`\n${errors.length} participant(s) failed:`);
258
+ for (const { index, error } of errors) {
259
+ console.error(` Participant ${index}: ${error}`);
260
+ }
261
+ }
262
+
263
+ console.log(`Simulation complete. ${completed - errors.length}/${participantCount} participants simulated successfully.`);
264
+ } finally {
265
+ backend.kill();
266
+ console.log('Backend stopped.');
267
+ }
268
+ }
@@ -0,0 +1,299 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import { FileUpload, Store, TrialData } from './common';
4
+
5
+ type DataObject = {
6
+ [key: string]: string | number | boolean | null | undefined;
7
+ };
8
+
9
+ export function escapeCsvValue(value: any): string {
10
+ if (value === null || value === undefined) {
11
+ return '';
12
+ }
13
+
14
+ const stringValue = String(value);
15
+
16
+ if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
17
+ const escapedValue = stringValue.replace(/"/g, '""');
18
+ return `"${escapedValue}"`;
19
+ }
20
+
21
+ return stringValue;
22
+ }
23
+
24
+ export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
25
+ if (!data || data.length === 0) {
26
+ return '';
27
+ }
28
+
29
+ const headerSet = new Set<string>();
30
+ data.forEach((obj) => {
31
+ Object.keys(obj).forEach((key) => {
32
+ headerSet.add(key);
33
+ });
34
+ });
35
+ const headers = Array.from(headerSet);
36
+
37
+ const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
38
+
39
+ const dataRows = data.map((obj) => {
40
+ return headers
41
+ .map((header) => {
42
+ const value = obj[header];
43
+ return escapeCsvValue(value);
44
+ })
45
+ .join(',');
46
+ });
47
+
48
+ return [headerRow, ...dataRows].join('\n');
49
+ }
50
+
51
+ export const defaultFlatteningFunctions: Record<
52
+ string,
53
+ (item: TrialData) => any[] | Record<string, any[]>
54
+ > = {
55
+ CanvasBlock: (item: TrialData) => {
56
+ const responseData = item.responseData;
57
+ if (Array.isArray(responseData)) {
58
+ return responseData.map((i) => ({
59
+ block: item.name,
60
+ ...i,
61
+ }));
62
+ }
63
+ return [];
64
+ },
65
+ };
66
+
67
+ export const transform = ({ responseData, ...obj }: any) => ({
68
+ ...obj,
69
+ ...Object.entries(responseData || {}).reduce(
70
+ (acc, [k, v]) => ({ ...acc, [`data_${k}`]: v }),
71
+ {},
72
+ ),
73
+ });
74
+
75
+ export type CSVBuilder = {
76
+ filename?: string;
77
+ trials?: string[];
78
+ fun?: (row: Record<string, any>) => Record<string, any>;
79
+ };
80
+
81
+ export function combineTrialsToCsv(
82
+ data: any[],
83
+ filename: string,
84
+ names: string[],
85
+ flatteningFunctions: Record<string, (item: any) => any[] | Record<string, any[]>>,
86
+ fun?: (obj: any) => any,
87
+ ): FileUpload | FileUpload[] {
88
+ // Collect all flattener results first, filtering out completely empty results
89
+ const allResults: (any[] | Record<string, any[]>)[] = names
90
+ .flatMap((name) => {
91
+ const matchingItems = data.filter((d) => d.name === name);
92
+
93
+ return matchingItems.map((item) => {
94
+ const flattener = item.type && flatteningFunctions[item.type];
95
+ const result = flattener ? flattener(item) : [transform(item)];
96
+
97
+ if (Array.isArray(result) && result.length === 0) {
98
+ return null;
99
+ }
100
+
101
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
102
+ const hasAnyData = Object.values(result).some(
103
+ (val) => Array.isArray(val) && val.length > 0,
104
+ );
105
+ if (!hasAnyData) {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ return result;
111
+ });
112
+ })
113
+ .filter((result) => result !== null);
114
+
115
+ const hasMultiTable = allResults.some(
116
+ (result) =>
117
+ result &&
118
+ typeof result === 'object' &&
119
+ !Array.isArray(result) &&
120
+ Object.keys(result).some((key) => Array.isArray(result[key])),
121
+ );
122
+
123
+ if (!hasMultiTable) {
124
+ const processedData = allResults
125
+ .flatMap((result) => (Array.isArray(result) ? result : []))
126
+ .map((x) => (fun ? fun(x) : x));
127
+
128
+ if (processedData.length === 0) {
129
+ return [];
130
+ }
131
+
132
+ return {
133
+ filename,
134
+ encoding: 'utf8' as const,
135
+ content: convertArrayOfObjectsToCSV(processedData),
136
+ };
137
+ }
138
+
139
+ const allTableKeys = new Set<string>();
140
+ allResults.forEach((result) => {
141
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
142
+ Object.keys(result).forEach((key) => {
143
+ if (Array.isArray(result[key])) {
144
+ allTableKeys.add(key);
145
+ }
146
+ });
147
+ }
148
+ });
149
+
150
+ const files: FileUpload[] = [];
151
+
152
+ for (const tableKey of allTableKeys) {
153
+ const tableData = allResults
154
+ .flatMap((result) => {
155
+ if (Array.isArray(result)) {
156
+ return result;
157
+ } else if (result && typeof result === 'object' && result[tableKey]) {
158
+ return result[tableKey];
159
+ }
160
+ return [];
161
+ })
162
+ .map((x) => (fun ? fun(x) : x));
163
+
164
+ if (tableData.length === 0) {
165
+ continue;
166
+ }
167
+
168
+ const baseFilename = filename.replace(/\.csv$/, '');
169
+
170
+ files.push({
171
+ filename: `${baseFilename}_${tableKey}.csv`,
172
+ encoding: 'utf8' as const,
173
+ content: convertArrayOfObjectsToCSV(tableData),
174
+ });
175
+ }
176
+
177
+ if (files.length === 0) {
178
+ return [];
179
+ }
180
+
181
+ return files.length === 1 ? files[0] : files;
182
+ }
183
+
184
+ export function buildUploadFiles(config: {
185
+ sessionID: string;
186
+ data: any[];
187
+ store?: Store;
188
+ generateFiles?: (sessionID: string, data: any[], store?: Store) => FileUpload[];
189
+ sessionCSVBuilder?: CSVBuilder;
190
+ trialCSVBuilder?: {
191
+ flatteners: Record<string, (item: any) => any[] | Record<string, any[]>>;
192
+ builders: CSVBuilder[];
193
+ };
194
+ uploadRaw?: boolean;
195
+ }): FileUpload[] {
196
+ const {
197
+ sessionID,
198
+ data,
199
+ store,
200
+ generateFiles,
201
+ sessionCSVBuilder,
202
+ trialCSVBuilder,
203
+ uploadRaw = true,
204
+ } = config;
205
+
206
+ const files: FileUpload[] = generateFiles ? generateFiles(sessionID, data, store) : [];
207
+
208
+ if (uploadRaw) {
209
+ files.push({
210
+ filename: `${sessionID}.raw.json`,
211
+ content: JSON.stringify(data),
212
+ encoding: 'utf8',
213
+ });
214
+ }
215
+
216
+ if (sessionCSVBuilder) {
217
+ type ParamDetails = {
218
+ value?: any;
219
+ defaultValue: any;
220
+ };
221
+ let paramsDict: Record<string, any> = {};
222
+ const paramsSource: Record<string, ParamDetails | any> | undefined =
223
+ data?.[0]?.responseData?.params;
224
+ if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
225
+ paramsDict = Object.entries(paramsSource).reduce(
226
+ (
227
+ accumulator: Record<string, any>,
228
+ [paramName, paramDetails]: [string, ParamDetails | any],
229
+ ) => {
230
+ if (
231
+ paramDetails &&
232
+ typeof paramDetails === 'object' &&
233
+ 'defaultValue' in paramDetails
234
+ ) {
235
+ const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
236
+ accumulator[paramName] = chosenValue;
237
+ }
238
+ return accumulator;
239
+ },
240
+ {} as Record<string, any>,
241
+ );
242
+ }
243
+
244
+ let content: Record<string, any> = {
245
+ sessionID,
246
+ userAgent: data?.[0]?.responseData?.userAgent,
247
+ ...paramsDict,
248
+ };
249
+
250
+ if (
251
+ sessionCSVBuilder.trials &&
252
+ Array.isArray(sessionCSVBuilder.trials) &&
253
+ sessionCSVBuilder.trials.length > 0
254
+ ) {
255
+ for (const trialName of sessionCSVBuilder.trials) {
256
+ const matchingDataElement = data.find((element) => element.name === trialName);
257
+
258
+ if (matchingDataElement?.responseData) {
259
+ if (
260
+ typeof matchingDataElement.responseData === 'object' &&
261
+ matchingDataElement.responseData !== null
262
+ ) {
263
+ content = { ...content, ...matchingDataElement.responseData };
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ files.push({
270
+ content: convertArrayOfObjectsToCSV([
271
+ sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
272
+ ]),
273
+ filename: `${sessionID}${sessionCSVBuilder.filename}.csv`,
274
+ encoding: 'utf8' as const,
275
+ });
276
+ }
277
+
278
+ if (trialCSVBuilder) {
279
+ for (const builder of trialCSVBuilder.builders) {
280
+ const result = combineTrialsToCsv(
281
+ data,
282
+ `${sessionID}${builder.filename}.csv`,
283
+ builder.trials ?? [],
284
+ { ...defaultFlatteningFunctions, ...trialCSVBuilder.flatteners },
285
+ builder.fun,
286
+ );
287
+
288
+ if (Array.isArray(result)) {
289
+ if (result.length > 0) {
290
+ files.push(...result);
291
+ }
292
+ } else {
293
+ files.push(result);
294
+ }
295
+ }
296
+ }
297
+
298
+ return files;
299
+ }
@@ -85,6 +85,65 @@ To update the app, simply stop the running containers, run a `git pull` and buil
85
85
  The server will create a "data" directory in the root directory of the cloned repo,
86
86
 
87
87
 
88
+ ## Simulation
89
+
90
+ You can simulate your experiment headlessly to generate synthetic data, test your data pipeline, or plan sample sizes.
91
+
92
+ ### Setup
93
+
94
+ Define your participant generator and simulation config in `Experiment.tsx`:
95
+
96
+ ```tsx
97
+ export const simulationConfig = {
98
+ participants: {
99
+ generator: (i) => ({ id: i, nickname: `participant_${i}` }),
100
+ count: 10,
101
+ },
102
+ };
103
+ ```
104
+
105
+ ### Running
106
+
107
+ ```
108
+ npm run simulate
109
+ ```
110
+
111
+ This starts the backend, simulates all participants through the experiment, and shuts down. The simulated data ends up in `backend/data/`.
112
+
113
+ ### Customizing participant behavior
114
+
115
+ Each trial component has default decision functions that model participant behavior. Override them per-trial using the `simulators` property on a timeline item:
116
+
117
+ ```tsx
118
+ {
119
+ type: 'PlainInput',
120
+ props: { content: <p>What is your name?</p> },
121
+ simulators: {
122
+ respond: (_trialProps, participant) => ({
123
+ value: participant.nickname,
124
+ participantState: participant,
125
+ }),
126
+ },
127
+ }
128
+ ```
129
+
130
+ ### Custom components
131
+
132
+ Register simulations for your custom trial components using `registerSimulation`. See the `CustomTrial` example in `Experiment.tsx` and the [reactive README](https://github.com/adriansteffan/reactive) for details.
133
+
134
+ ### Hybrid mode
135
+
136
+ During development, auto-advance simulated trials while manually interacting with others:
137
+
138
+ Add `?hybridSimulation=true` to the URL:
139
+
140
+ ```
141
+ http://localhost:5173?hybridSimulation=true
142
+ ```
143
+
144
+ Trials with `simulators` or `simulate: true` defined will auto-advance. Others render normally for human interaction. Hybrid mode is enabled by default during development. For production, set `VITE_DISABLE_HYBRID_SIMULATION=true` to disable it.
145
+
146
+
88
147
  ## Target: Windows or MacOS
89
148
 
90
149
  ### Development
@@ -95,5 +95,6 @@ if (process.env.NODE_ENV === 'production') {
95
95
  }
96
96
 
97
97
  app.listen(PORT, () => {
98
+ console.log(`REACTIVE_BACKEND_READY`);
98
99
  console.log(`Server is Fire at http://localhost:${PORT}`);
99
100
  });
@@ -47,6 +47,7 @@
47
47
  "dev": "vite",
48
48
  "backend": "npm run dev --prefix backend",
49
49
  "dev:all": "concurrently -n \"FRONT,BACK\" -c \"blue,green\" \"npm run dev\" \"npm run backend\"",
50
+ "simulate": "npx tsx simulate.ts",
50
51
  "build": "tsc && vite build",
51
52
  "test": "vitest --silent=false",
52
53
  "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
@@ -85,6 +86,7 @@
85
86
  "@typescript-eslint/parser": "^8.16.0",
86
87
  "@vitejs/plugin-react": "^4.2.1",
87
88
  "concurrently": "^9.1.0",
89
+ "tsx": "^4.19.2",
88
90
  "electron": "^35.0.0",
89
91
  "electron-builder": "^25.1.8",
90
92
  "electron-vite": "^3.0.0",
@@ -0,0 +1,15 @@
1
+ import { orchestrateSimulation, simulateParticipant, setBackendUrl } from '@adriansteffan/reactive';
2
+ import { experiment, simulationConfig } from './src/Experiment';
3
+
4
+ // Each simulated participant runs as a separate subprocess to get fresh module-level
5
+ // randomization (e.g., group assignment). The orchestrator spawns workers that re-run
6
+ // this script with _REACTIVE_WORKER_INDEX set.
7
+ if (process.env._REACTIVE_WORKER_INDEX) {
8
+ const index = parseInt(process.env._REACTIVE_WORKER_INDEX);
9
+ const participants = simulationConfig.participants;
10
+ const participant = Array.isArray(participants) ? participants[index] : participants.generator(index);
11
+ setBackendUrl(process.env._REACTIVE_BACKEND_URL!);
12
+ await simulateParticipant(experiment, participant);
13
+ } else {
14
+ await orchestrateSimulation(simulationConfig, import.meta.filename);
15
+ }