@adriansteffan/reactive 0.0.43 → 0.1.0

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 (41) hide show
  1. package/.claude/settings.local.json +14 -1
  2. package/README.md +232 -3
  3. package/dist/{mod-D6W3wq3h.js → mod-D9lwPIrH.js} +6739 -6389
  4. package/dist/mod.d.ts +70 -22
  5. package/dist/reactive.es.js +46 -36
  6. package/dist/reactive.umd.js +40 -38
  7. package/dist/style.css +1 -1
  8. package/dist/{web-B1hJOwit.js → web-DUIQX1PV.js} +1 -1
  9. package/dist/{web-BYSmfdtR.js → web-DXP3LAJm.js} +1 -1
  10. package/package.json +1 -1
  11. package/src/components/canvasblock.tsx +125 -74
  12. package/src/components/checkdevice.tsx +18 -0
  13. package/src/components/enterfullscreen.tsx +7 -3
  14. package/src/components/exitfullscreen.tsx +6 -1
  15. package/src/components/experimentprovider.tsx +7 -2
  16. package/src/components/experimentrunner.tsx +85 -58
  17. package/src/components/microphonecheck.tsx +6 -1
  18. package/src/components/mobilefilepermission.tsx +3 -0
  19. package/src/components/plaininput.tsx +20 -0
  20. package/src/components/prolificending.tsx +5 -0
  21. package/src/components/quest.tsx +60 -0
  22. package/src/components/storeui.tsx +18 -11
  23. package/src/components/text.tsx +14 -0
  24. package/src/components/upload.tsx +69 -286
  25. package/src/index.css +0 -20
  26. package/src/mod.tsx +2 -0
  27. package/src/utils/bytecode.ts +61 -9
  28. package/src/utils/common.ts +4 -1
  29. package/src/utils/simulation.ts +269 -0
  30. package/src/utils/upload.ts +201 -0
  31. package/template/README.md +59 -0
  32. package/template/backend/package-lock.json +280 -156
  33. package/template/backend/src/backend.ts +1 -0
  34. package/template/package-lock.json +1693 -771
  35. package/template/package.json +2 -0
  36. package/template/simulate.ts +15 -0
  37. package/template/src/Experiment.tsx +62 -5
  38. package/template/src/main.tsx +1 -1
  39. package/template/tsconfig.json +2 -3
  40. package/tsconfig.json +1 -0
  41. package/vite.config.ts +1 -1
@@ -11,6 +11,8 @@ type BaseTrialData = {
11
11
  export type ComponentResultData = BaseTrialData & {
12
12
  type: string;
13
13
  name: string;
14
+ /** Per-trial override for which CSV file(s) this trial's data goes into. When set on a timeline item, overrides the component type's default from the flattener registry. */
15
+ csv?: string | string[];
14
16
  responseData?: any;
15
17
  metadata?: Record<string, any>;
16
18
  };
@@ -21,16 +23,16 @@ export type CanvasResultData = BaseTrialData & {
21
23
  reactionTime: number | null;
22
24
  };
23
25
 
24
- export type RefinedTrialData = ComponentResultData | CanvasResultData;
26
+ export type TrialResult = ComponentResultData | CanvasResultData;
25
27
 
26
28
  export interface MarkerItem {
27
29
  type: 'MARKER';
28
30
  id: string;
29
31
  }
30
32
 
31
- export type ConditionalFunction = (data?: RefinedTrialData[], store?: Store) => boolean;
33
+ export type ConditionalFunction = (data?: TrialResult[], store?: Store) => boolean;
32
34
 
33
- export type StoreUpdateFunction = (data?: RefinedTrialData[], store?: Store) => Record<string, any>;
35
+ export type StoreUpdateFunction = (data?: TrialResult[], store?: Store) => Record<string, any>;
34
36
 
35
37
  export interface IfGotoItem {
36
38
  type: 'IF_GOTO';
@@ -65,12 +67,12 @@ export interface ExecuteContentInstruction {
65
67
  }
66
68
  export interface IfGotoInstruction {
67
69
  type: 'IfGoto';
68
- cond: (store: Store, data: RefinedTrialData[]) => boolean;
70
+ cond: (store: Store, data: TrialResult[]) => boolean;
69
71
  marker: string;
70
72
  }
71
73
  export interface UpdateStoreInstruction {
72
74
  type: 'UpdateStore';
73
- fun: (store: Store, data: RefinedTrialData[]) => Store;
75
+ fun: (store: Store, data: TrialResult[]) => Store;
74
76
  }
75
77
  export type UnifiedBytecodeInstruction =
76
78
  | ExecuteContentInstruction
@@ -95,16 +97,16 @@ export function compileTimeline(timeline: TimelineItem[]): {
95
97
 
96
98
  function adaptCondition(
97
99
  userCondition: ConditionalFunction,
98
- ): (store: Store, data: RefinedTrialData[]) => boolean {
99
- return (runtimeStore: Store, runtimeData: RefinedTrialData[]): boolean => {
100
+ ): (store: Store, data: TrialResult[]) => boolean {
101
+ return (runtimeStore: Store, runtimeData: TrialResult[]): boolean => {
100
102
  return userCondition(runtimeData, runtimeStore);
101
103
  };
102
104
  }
103
105
 
104
106
  function adaptUpdate(
105
107
  userUpdateFunction: StoreUpdateFunction,
106
- ): (store: Store, data: RefinedTrialData[]) => Store {
107
- return (runtimeStore: Store, runtimeData: RefinedTrialData[]): Store => {
108
+ ): (store: Store, data: TrialResult[]) => Store {
109
+ return (runtimeStore: Store, runtimeData: TrialResult[]): Store => {
108
110
  const updates = userUpdateFunction(runtimeData, runtimeStore);
109
111
  if (typeof updates === 'object' && updates !== null) {
110
112
  return {
@@ -207,3 +209,53 @@ export function compileTimeline(timeline: TimelineItem[]): {
207
209
 
208
210
  return { instructions, markers };
209
211
  }
212
+
213
+ /**
214
+ * Walks bytecode from `fromPointer`, processing IfGoto and UpdateStore instructions,
215
+ * and returns the pointer of the next ExecuteContent (or past-end if none found).
216
+ */
217
+ export function applyMetadata<T extends Record<string, any>>(
218
+ trialData: T,
219
+ content: { metadata?: any; nestMetadata?: boolean },
220
+ data: TrialResult[],
221
+ store: Store,
222
+ ): T {
223
+ const metadata = typeof content.metadata === 'function' ? content.metadata(data, store) : content.metadata;
224
+ if (content.nestMetadata) return { ...trialData, metadata };
225
+ if (metadata) return { ...metadata, ...trialData };
226
+ return trialData;
227
+ }
228
+
229
+ export function advanceToNextContent(
230
+ bytecode: { instructions: UnifiedBytecodeInstruction[]; markers: Record<string, number> },
231
+ fromPointer: number,
232
+ getStore: () => Store,
233
+ getData: () => TrialResult[],
234
+ onUpdateStore: (newStore: Store) => void,
235
+ ): number {
236
+ let pointer = fromPointer;
237
+ while (pointer < bytecode.instructions.length) {
238
+ const instr = bytecode.instructions[pointer];
239
+ switch (instr.type) {
240
+ case 'ExecuteContent':
241
+ return pointer;
242
+ case 'IfGoto':
243
+ if (instr.cond(getStore(), getData())) {
244
+ const target = bytecode.markers[instr.marker];
245
+ if (target !== undefined) {
246
+ pointer = target;
247
+ continue;
248
+ }
249
+ }
250
+ pointer++;
251
+ break;
252
+ case 'UpdateStore':
253
+ onUpdateStore(instr.fun(getStore(), getData()));
254
+ pointer++;
255
+ break;
256
+ default:
257
+ pointer++;
258
+ }
259
+ }
260
+ return pointer;
261
+ }
@@ -24,6 +24,8 @@ export interface TrialData {
24
24
  trialNumber: number;
25
25
  type: string;
26
26
  name: string;
27
+ /** Populated from the timeline item's csv field. Overrides the component type's default CSV target from the flattener registry. */
28
+ csv?: string | string[];
27
29
  responseData: any;
28
30
  start: number;
29
31
  end: number;
@@ -68,13 +70,14 @@ export function getParam<T extends ParamType>(
68
70
  defaultValue: ParamValue<T>,
69
71
  type: T = 'string' as T,
70
72
  description?: string,
73
+ uiDefault?: string,
71
74
  ): ParamValue<T> {
72
75
  let registryEntry = sharedRegistry.find((p) => p.name === name);
73
76
 
74
77
  if (!registryEntry) {
75
78
  registryEntry = {
76
79
  name,
77
- defaultValue,
80
+ defaultValue: uiDefault !== undefined ? uiDefault : defaultValue,
78
81
  type,
79
82
  description,
80
83
  value: undefined,
@@ -0,0 +1,269 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import {
4
+ compileTimeline,
5
+ advanceToNextContent,
6
+ applyMetadata,
7
+ TrialResult,
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: TrialResult[];
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: TrialResult[], 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<TrialResult[]> {
84
+ _initialParticipant = { ...participant };
85
+ let currentParticipantState = { ...participant };
86
+ const bytecode = compileTimeline(timeline);
87
+ let store: Store = {};
88
+ const data: TrialResult[] = [
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
+ ...(content.csv !== undefined ? { csv: content.csv } : {}),
142
+ responseData: result.responseData,
143
+ };
144
+
145
+ data.push(applyMetadata(trialData, content, data, store));
146
+ pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
147
+ }
148
+
149
+ return data;
150
+ }
151
+
152
+
153
+ export interface RunSimulationConfig {
154
+ participants: ParticipantState[] | { generator: (index: number) => ParticipantState; count: number };
155
+ backendPort?: number;
156
+ concurrency?: number;
157
+ }
158
+
159
+ export async function orchestrateSimulation(config: RunSimulationConfig, scriptPath: string): Promise<void> {
160
+ const { spawn } = await import('child_process');
161
+
162
+ const port = config.backendPort ?? 8001;
163
+ const backendUrl = `http://localhost:${port}/backend`;
164
+
165
+ const participantCount = Array.isArray(config.participants)
166
+ ? config.participants.length
167
+ : config.participants.count;
168
+
169
+ const backend = spawn('npx', ['tsx', 'src/backend.ts'], {
170
+ cwd: './backend',
171
+ stdio: ['ignore', 'pipe', 'pipe'],
172
+ });
173
+
174
+ // Wait for backend to signal readiness before spawning workers
175
+ await new Promise<void>((resolve, reject) => {
176
+ backend.on('error', reject);
177
+
178
+ backend.stdout?.on('data', (data: any) => {
179
+ const msg = data.toString();
180
+ if (msg.includes('REACTIVE_BACKEND_READY')) {
181
+ resolve();
182
+ }
183
+ });
184
+
185
+ backend.stderr?.on('data', (data: any) => {
186
+ console.error(`[backend] ${data.toString().trim()}`);
187
+ });
188
+
189
+ setTimeout(() => reject(new Error('Backend did not start within 10 seconds')), 10000);
190
+ });
191
+
192
+ console.log(`Backend running on port ${port}`);
193
+
194
+ try {
195
+ const concurrency = config.concurrency ?? (await import('os')).cpus().length;
196
+ console.log(`Simulating ${participantCount} participants (concurrency: ${concurrency})...`);
197
+
198
+ let completed = 0;
199
+ const errors: { index: number; error: string }[] = [];
200
+
201
+ const writeProgress = () => {
202
+ const pct = Math.min(100, Math.round((completed / participantCount) * 100));
203
+ const filled = Math.floor(pct / 2);
204
+ const bar = '█'.repeat(filled) + '░'.repeat(50 - filled);
205
+ process.stdout.write(`\r ${bar} ${completed}/${participantCount} (${pct}%)`);
206
+ };
207
+
208
+ const spawnWorker = (i: number) => new Promise<void>((resolve) => {
209
+ let stderr = '';
210
+ const worker = spawn('npx', ['tsx', scriptPath], {
211
+ env: {
212
+ ...process.env,
213
+ _REACTIVE_WORKER_INDEX: String(i),
214
+ _REACTIVE_BACKEND_URL: backendUrl,
215
+ },
216
+ stdio: ['ignore', 'pipe', 'pipe'],
217
+ });
218
+
219
+ worker.stderr?.on('data', (data: any) => { stderr += data.toString(); });
220
+
221
+ worker.on('close', (code: number) => {
222
+ if (code !== 0) {
223
+ errors.push({ index: i, error: stderr.trim() || `exited with code ${code}` });
224
+ }
225
+ completed++;
226
+ writeProgress();
227
+ resolve();
228
+ });
229
+ worker.on('error', (err: Error) => {
230
+ errors.push({ index: i, error: err.message });
231
+ completed++;
232
+ writeProgress();
233
+ resolve();
234
+ });
235
+ });
236
+
237
+ writeProgress();
238
+
239
+ // Worker pool: always keep `concurrency` workers running
240
+ let nextIndex = 0;
241
+ await new Promise<void>((resolveAll) => {
242
+ const startNext = () => {
243
+ if (nextIndex >= participantCount) {
244
+ if (completed >= participantCount) resolveAll();
245
+ return;
246
+ }
247
+ const i = nextIndex++;
248
+ spawnWorker(i).then(startNext);
249
+ };
250
+ for (let j = 0; j < Math.min(concurrency, participantCount); j++) {
251
+ startNext();
252
+ }
253
+ });
254
+
255
+ process.stdout.write('\n');
256
+
257
+ if (errors.length > 0) {
258
+ console.error(`\n${errors.length} participant(s) failed:`);
259
+ for (const { index, error } of errors) {
260
+ console.error(` Participant ${index}: ${error}`);
261
+ }
262
+ }
263
+
264
+ console.log(`Simulation complete. ${completed - errors.length}/${participantCount} participants simulated successfully.`);
265
+ } finally {
266
+ backend.kill();
267
+ console.log('Backend stopped.');
268
+ }
269
+ }
@@ -0,0 +1,201 @@
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
+
10
+ const flattenerRegistry: Record<string, {
11
+ csv: string | null;
12
+ flatten?: (item: TrialData) => any[];
13
+ }[]> = {};
14
+
15
+ export function registerFlattener(
16
+ type: string,
17
+ csv: string | null,
18
+ flatten?: (item: TrialData) => any[],
19
+ ) {
20
+ if (!flattenerRegistry[type]) flattenerRegistry[type] = [];
21
+ flattenerRegistry[type].push({ csv, flatten });
22
+ }
23
+
24
+ export function escapeCsvValue(value: any): string {
25
+ if (value === null || value === undefined) {
26
+ return '';
27
+ }
28
+
29
+ const stringValue = String(value);
30
+
31
+ if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
32
+ const escapedValue = stringValue.replace(/"/g, '""');
33
+ return `"${escapedValue}"`;
34
+ }
35
+
36
+ return stringValue;
37
+ }
38
+
39
+ export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
40
+ if (!data || data.length === 0) {
41
+ return '';
42
+ }
43
+
44
+ const headerSet = new Set<string>();
45
+ data.forEach((obj) => {
46
+ Object.keys(obj).forEach((key) => {
47
+ headerSet.add(key);
48
+ });
49
+ });
50
+ const headers = Array.from(headerSet);
51
+
52
+ const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
53
+
54
+ const dataRows = data.map((obj) => {
55
+ return headers
56
+ .map((header) => {
57
+ const value = obj[header];
58
+ return escapeCsvValue(value);
59
+ })
60
+ .join(',');
61
+ });
62
+
63
+ return [headerRow, ...dataRows].join('\n');
64
+ }
65
+
66
+ // Extracts experiment parameter values (URL params / registered params) from the initial metadata trial.
67
+ // Each param entry has { value, defaultValue, ... } — we pick value if set, otherwise the default.
68
+ function extractParams(data: any[]): Record<string, any> {
69
+ const paramsSource = data?.[0]?.responseData?.params;
70
+ if (!paramsSource || typeof paramsSource !== 'object') return {};
71
+ const result: Record<string, any> = {};
72
+ for (const [name, details] of Object.entries(paramsSource) as [string, any][]) {
73
+ if (details && typeof details === 'object') {
74
+ result[name] = details.value ?? details.defaultValue;
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ // Built-in fields on every trial data object — not user data.
81
+ // Used to distinguish user-provided metadata from framework fields when building CSVs.
82
+ const trialBuiltinKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name', 'csv', 'responseData', 'metadata']);
83
+ // Subset of builtins that represent trial identity/timing — included in non-session CSVs with trial_ prefix
84
+ const trialInfoKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name']);
85
+
86
+ // Automatically builds CSV files from trial data using the flattener registry.
87
+ // Each trial is routed to a CSV file based on: item.csv override > registry default > skipped.
88
+ // The 'session' group is special: all trials merge into a single row, with keys namespaced
89
+ // by trial name (e.g. 'nickname_value', 'devicecheck_browser') to avoid collisions.
90
+ // All other groups produce multi-row CSVs, one row per trial (or more if the flattener expands them).
91
+ function autoBuildCSVs(sessionID: string, data: any[], sessionData?: Record<string, any>): FileUpload[] {
92
+ const files: FileUpload[] = [];
93
+
94
+ // Group trials by their target CSV file name.
95
+ // A component can register multiple targets, so one trial may appear in multiple groups.
96
+ // A per-item csv override replaces all registered targets.
97
+ const groups: Record<string, { item: any; flatten?: (item: TrialData) => any[] }[]> = {};
98
+ for (const item of data) {
99
+ if (item.index === -1) continue; // skip initial metadata entry
100
+ const csvOverride = item.csv;
101
+ const targets = csvOverride
102
+ ? (Array.isArray(csvOverride) ? csvOverride : [csvOverride]).map((csv: string) => ({ csv }))
103
+ : flattenerRegistry[item.type] ?? [];
104
+ for (const target of targets) {
105
+ if (!target.csv) continue;
106
+ if (!groups[target.csv]) groups[target.csv] = [];
107
+ groups[target.csv].push({ item, flatten: 'flatten' in target ? target.flatten : undefined });
108
+ }
109
+ }
110
+
111
+ // 'session' group: one row per participant with metadata + responseData namespaced by trial name
112
+ if (groups['session']) {
113
+ const row: Record<string, any> = {
114
+ sessionID,
115
+ userAgent: data?.[0]?.responseData?.userAgent,
116
+ ...extractParams(data),
117
+ ...sessionData,
118
+ };
119
+ for (const { item } of groups['session']) {
120
+ const prefix = item.name || item.type;
121
+ // Add user-provided metadata (non-builtin top-level keys), namespaced by trial name
122
+ for (const [key, value] of Object.entries(item)) {
123
+ if (!trialBuiltinKeys.has(key)) row[`${prefix}_${key}`] = value;
124
+ }
125
+ // Add responseData fields, namespaced by trial name
126
+ if (item.responseData && typeof item.responseData === 'object' && !Array.isArray(item.responseData)) {
127
+ for (const [key, value] of Object.entries(item.responseData)) {
128
+ row[`${prefix}_${key}`] = value;
129
+ }
130
+ }
131
+ }
132
+ files.push({
133
+ filename: `session.${sessionID}.${Date.now()}.csv`,
134
+ content: convertArrayOfObjectsToCSV([row]),
135
+ encoding: 'utf8' as const,
136
+ });
137
+ delete groups['session'];
138
+ }
139
+
140
+ // All other groups: multi-row CSVs using registered flatteners (or raw responseData spread).
141
+ // Each row is prepended with standard trial fields (prefixed trial_) plus any extra
142
+ // metadata fields (unprefixed). The flattener's output overwrites these if keys collide.
143
+ for (const [csvName, entries] of Object.entries(groups)) {
144
+ const rows = entries.flatMap(({ item, flatten }) => {
145
+ const base: Record<string, any> = {};
146
+ for (const [key, value] of Object.entries(item)) {
147
+ if (trialBuiltinKeys.has(key)) {
148
+ if (trialInfoKeys.has(key)) base[`trial_${key}`] = value;
149
+ } else {
150
+ base[key] = value;
151
+ }
152
+ }
153
+ // When no flatten function is registered, spread responseData directly.
154
+ const flatRows = flatten
155
+ ? flatten(item)
156
+ : [item.responseData && typeof item.responseData === 'object' ? { ...item.responseData } : {}];
157
+ return flatRows.map((row: any) => ({ ...base, ...row }));
158
+ });
159
+ if (rows.length > 0) {
160
+ files.push({
161
+ filename: `${csvName}.${sessionID}.${Date.now()}.csv`,
162
+ content: convertArrayOfObjectsToCSV(rows),
163
+ encoding: 'utf8' as const,
164
+ });
165
+ }
166
+ }
167
+
168
+ return files;
169
+ }
170
+
171
+ export function buildUploadFiles(config: {
172
+ sessionID: string;
173
+ data: any[];
174
+ store?: Store;
175
+ generateFiles?: (sessionID: string, data: any[], store?: Store) => FileUpload[];
176
+ sessionData?: Record<string, any>;
177
+ uploadRaw?: boolean;
178
+ }): FileUpload[] {
179
+ const {
180
+ sessionID,
181
+ data,
182
+ store,
183
+ generateFiles,
184
+ sessionData,
185
+ uploadRaw = true,
186
+ } = config;
187
+
188
+ const files: FileUpload[] = generateFiles ? generateFiles(sessionID, data, store) : [];
189
+
190
+ if (uploadRaw) {
191
+ files.push({
192
+ filename: `${sessionID}.raw.json`,
193
+ content: JSON.stringify(data),
194
+ encoding: 'utf8',
195
+ });
196
+ }
197
+
198
+ files.push(...autoBuildCSVs(sessionID, data, sessionData));
199
+
200
+ return files;
201
+ }
@@ -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