@auraindustry/aurajs 0.0.1

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.
@@ -0,0 +1,549 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ import { runHeadlessTest } from './headless-test.mjs';
5
+ import { resolveGateHostBinary } from './host-binary.mjs';
6
+
7
+ export const PERF_REPORT_SCHEMA = 'aurajs.perf-benchmark.v2';
8
+ export const DEFAULT_PERF_ARTIFACT_ROOT = '.aura/perf';
9
+ export const DEFAULT_PERF_WORKSPACE_ROOT = `${DEFAULT_PERF_ARTIFACT_ROOT}/workspace`;
10
+ export const DEFAULT_PERF_REPORT_PATH = `${DEFAULT_PERF_ARTIFACT_ROOT}/report.json`;
11
+ export const DEFAULT_PERF_EVALUATION_PATH = `${DEFAULT_PERF_ARTIFACT_ROOT}/threshold-evaluation.json`;
12
+ export const DEFAULT_STUTTER_THRESHOLD_MS = 25;
13
+
14
+ export const DEFAULT_PERF_SCENES = [
15
+ {
16
+ id: 'draw2d_primitives',
17
+ frames: 300,
18
+ source: `
19
+ let image = null;
20
+
21
+ aura.setup = function () {
22
+ image = aura.assets.image('player.png');
23
+ };
24
+
25
+ aura.draw = function () {
26
+ aura.draw2d.clear(6, 9, 16);
27
+
28
+ for (let i = 0; i < 120; i += 1) {
29
+ const x = (i * 7) % aura.window.width;
30
+ const y = (i * 11) % aura.window.height;
31
+ aura.draw2d.rect(x, y, 24, 16, aura.colors.white);
32
+ aura.draw2d.circle(x + 10, y + 8, 6, aura.colors.red);
33
+ aura.draw2d.line(0, 0, x, y, aura.colors.white, 1);
34
+ aura.draw2d.image(image, x, y, { width: 16, height: 16 });
35
+ }
36
+ };
37
+ `,
38
+ },
39
+ {
40
+ id: 'sprite_transform_swarm',
41
+ frames: 360,
42
+ source: `
43
+ let image = null;
44
+ const swarm = [];
45
+ let elapsed = 0;
46
+
47
+ aura.setup = function () {
48
+ image = aura.assets.image('player.png');
49
+
50
+ for (let i = 0; i < 240; i += 1) {
51
+ swarm.push({
52
+ lane: i % 18,
53
+ phase: i * 0.13,
54
+ radius: 36 + (i % 12) * 14,
55
+ baseScale: 0.5 + ((i % 5) * 0.2),
56
+ speed: 0.75 + ((i % 7) * 0.11),
57
+ });
58
+ }
59
+ };
60
+
61
+ aura.update = function (dt) {
62
+ elapsed += dt;
63
+ };
64
+
65
+ aura.draw = function () {
66
+ aura.draw2d.clear(5, 7, 14);
67
+
68
+ const centerX = aura.window.width * 0.5;
69
+ const centerY = aura.window.height * 0.5;
70
+
71
+ for (let i = 0; i < swarm.length; i += 1) {
72
+ const unit = swarm[i];
73
+ const theta = (elapsed * unit.speed * 3.5) + unit.phase;
74
+ const wobble = Math.sin(theta * 1.9) * 10;
75
+ const x = centerX + Math.cos(theta) * (unit.radius + wobble);
76
+ const y = ((unit.lane * 38) + (Math.sin(theta * 1.3) * 14) + centerY * 0.2) % aura.window.height;
77
+ const stretch = 0.8 + ((Math.sin(theta * 2.2) + 1) * 0.22);
78
+
79
+ aura.draw2d.image(image, x, y, {
80
+ width: 20,
81
+ height: 20,
82
+ originX: 0.5,
83
+ originY: 0.5,
84
+ rotation: theta,
85
+ scaleX: unit.baseScale * stretch,
86
+ scaleY: unit.baseScale / stretch,
87
+ alpha: 0.9,
88
+ });
89
+ }
90
+ };
91
+ `,
92
+ },
93
+ {
94
+ id: 'math_collision',
95
+ frames: 300,
96
+ source: `
97
+ let accumulator = 0;
98
+
99
+ aura.update = function () {
100
+ for (let i = 0; i < 500; i += 1) {
101
+ const t = (i % 100) / 100;
102
+ const v = aura.math.lerp(0, 500, t);
103
+ const d = aura.math.distance(0, 0, v, v / 2);
104
+ const hit = aura.collide.rects(v, v, 10, 10, v + 2, v + 2, 8, 8);
105
+ if (hit) {
106
+ accumulator += d;
107
+ }
108
+ }
109
+ };
110
+
111
+ aura.draw = function () {
112
+ aura.draw2d.clear(0, 0, 0);
113
+ aura.draw2d.text(String(accumulator), 4, 4, { color: aura.colors.white, size: 12 });
114
+ };
115
+ `,
116
+ },
117
+ ];
118
+
119
+ function mean(values) {
120
+ if (values.length === 0) return 0;
121
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
122
+ }
123
+
124
+ function stdDev(values) {
125
+ if (values.length === 0) return 0;
126
+ const avg = mean(values);
127
+ const variance = mean(values.map((value) => (value - avg) ** 2));
128
+ return Math.sqrt(variance);
129
+ }
130
+
131
+ function median(values) {
132
+ if (values.length === 0) return 0;
133
+ const sorted = [...values].sort((a, b) => a - b);
134
+ const mid = Math.floor(sorted.length / 2);
135
+ if (sorted.length % 2 === 0) {
136
+ return (sorted[mid - 1] + sorted[mid]) / 2;
137
+ }
138
+ return sorted[mid];
139
+ }
140
+
141
+ function percentile(values, percentileValue) {
142
+ if (values.length === 0) return 0;
143
+ const sorted = [...values].sort((a, b) => a - b);
144
+ const clampedPercentile = Math.max(0, Math.min(100, percentileValue));
145
+ const rank = (clampedPercentile / 100) * (sorted.length - 1);
146
+ const lowerIndex = Math.floor(rank);
147
+ const upperIndex = Math.ceil(rank);
148
+ if (lowerIndex === upperIndex) {
149
+ return sorted[lowerIndex];
150
+ }
151
+ const weight = rank - lowerIndex;
152
+ return sorted[lowerIndex] * (1 - weight) + sorted[upperIndex] * weight;
153
+ }
154
+
155
+ function countStutterBursts(frameTimes, thresholdMs) {
156
+ let bursts = 0;
157
+ let inBurst = false;
158
+ for (const frameTime of frameTimes) {
159
+ if (frameTime > thresholdMs) {
160
+ if (!inBurst) {
161
+ bursts += 1;
162
+ inBurst = true;
163
+ }
164
+ continue;
165
+ }
166
+ inBurst = false;
167
+ }
168
+ return bursts;
169
+ }
170
+
171
+ function measureCallLatency(fn, iterations = 250000) {
172
+ const started = process.hrtime.bigint();
173
+ for (let i = 0; i < iterations; i += 1) {
174
+ fn();
175
+ }
176
+ const ended = process.hrtime.bigint();
177
+ const totalNs = Number(ended - started);
178
+ return (totalNs / iterations) / 1_000_000;
179
+ }
180
+
181
+ function syntheticLatencies() {
182
+ const inputState = { down: false };
183
+ const audioState = { level: 1 };
184
+
185
+ const inputLatencyMs = measureCallLatency(() => inputState.down);
186
+
187
+ const audioLatencyMs = measureCallLatency(() => {
188
+ audioState.level = audioState.level > 0.99 ? 0 : audioState.level + 0.01;
189
+ return audioState.level;
190
+ });
191
+
192
+ return {
193
+ inputQueryMs: inputLatencyMs,
194
+ audioCallMs: audioLatencyMs,
195
+ };
196
+ }
197
+
198
+ function collectRuntimeCacheDiagnosticsVector() {
199
+ return {
200
+ status: 'fail',
201
+ reasonCode: 'runtime_cache_diag_missing',
202
+ vectors: null,
203
+ };
204
+ }
205
+
206
+ function buildPerfSlice(frameTimes = [], elapsedMs = null, options = {}) {
207
+ const stutterThresholdMs = Number.isFinite(options.stutterThresholdMs)
208
+ ? Number(options.stutterThresholdMs)
209
+ : DEFAULT_STUTTER_THRESHOLD_MS;
210
+ return {
211
+ elapsedMs,
212
+ sampleCount: frameTimes.length,
213
+ frameTimeSamplesMs: frameTimes,
214
+ avgFrameTimeMs: mean(frameTimes),
215
+ medianFrameTimeMs: median(frameTimes),
216
+ p50FrameTimeMs: percentile(frameTimes, 50),
217
+ p95FrameTimeMs: percentile(frameTimes, 95),
218
+ p99FrameTimeMs: percentile(frameTimes, 99),
219
+ jitterMs: stdDev(frameTimes),
220
+ stutterBurstCount: countStutterBursts(frameTimes, stutterThresholdMs),
221
+ stutterThresholdMs,
222
+ avgFps: mean(frameTimes) === 0 ? 0 : 1000 / mean(frameTimes),
223
+ };
224
+ }
225
+
226
+ function pushSliceFailures({ failures, sliceName, slice, limits, sceneId }) {
227
+ if (!limits || !slice) return;
228
+ if (typeof limits.maxAvgFrameTimeMs === 'number' && slice.avgFrameTimeMs > limits.maxAvgFrameTimeMs) {
229
+ failures.push({
230
+ type: `${sliceName}.scene.avgFrameTimeMs`,
231
+ section: sliceName,
232
+ scene: sceneId,
233
+ actual: slice.avgFrameTimeMs,
234
+ threshold: limits.maxAvgFrameTimeMs,
235
+ });
236
+ }
237
+ if (typeof limits.maxJitterMs === 'number' && slice.jitterMs > limits.maxJitterMs) {
238
+ failures.push({
239
+ type: `${sliceName}.scene.jitterMs`,
240
+ section: sliceName,
241
+ scene: sceneId,
242
+ actual: slice.jitterMs,
243
+ threshold: limits.maxJitterMs,
244
+ });
245
+ }
246
+ if (typeof limits.minFps === 'number' && slice.avgFps < limits.minFps) {
247
+ failures.push({
248
+ type: `${sliceName}.scene.avgFps`,
249
+ section: sliceName,
250
+ scene: sceneId,
251
+ actual: slice.avgFps,
252
+ threshold: limits.minFps,
253
+ });
254
+ }
255
+ if (typeof limits.maxP50FrameTimeMs === 'number' && slice.p50FrameTimeMs > limits.maxP50FrameTimeMs) {
256
+ failures.push({
257
+ type: `${sliceName}.scene.p50FrameTimeMs`,
258
+ section: sliceName,
259
+ scene: sceneId,
260
+ actual: slice.p50FrameTimeMs,
261
+ threshold: limits.maxP50FrameTimeMs,
262
+ });
263
+ }
264
+ if (typeof limits.maxP95FrameTimeMs === 'number' && slice.p95FrameTimeMs > limits.maxP95FrameTimeMs) {
265
+ failures.push({
266
+ type: `${sliceName}.scene.p95FrameTimeMs`,
267
+ section: sliceName,
268
+ scene: sceneId,
269
+ actual: slice.p95FrameTimeMs,
270
+ threshold: limits.maxP95FrameTimeMs,
271
+ });
272
+ }
273
+ if (typeof limits.maxP99FrameTimeMs === 'number' && slice.p99FrameTimeMs > limits.maxP99FrameTimeMs) {
274
+ failures.push({
275
+ type: `${sliceName}.scene.p99FrameTimeMs`,
276
+ section: sliceName,
277
+ scene: sceneId,
278
+ actual: slice.p99FrameTimeMs,
279
+ threshold: limits.maxP99FrameTimeMs,
280
+ });
281
+ }
282
+ if (typeof limits.maxStutterBurstCount === 'number' && slice.stutterBurstCount > limits.maxStutterBurstCount) {
283
+ failures.push({
284
+ type: `${sliceName}.scene.stutterBurstCount`,
285
+ section: sliceName,
286
+ scene: sceneId,
287
+ actual: slice.stutterBurstCount,
288
+ threshold: limits.maxStutterBurstCount,
289
+ });
290
+ }
291
+ }
292
+
293
+ export function loadPerfThresholds(thresholdPath = null) {
294
+ const resolvedPath = thresholdPath
295
+ ? resolve(thresholdPath)
296
+ : resolve(new URL('../benchmarks/perf-thresholds.json', import.meta.url).pathname);
297
+
298
+ const raw = JSON.parse(readFileSync(resolvedPath, 'utf8'));
299
+ return {
300
+ path: resolvedPath,
301
+ thresholds: raw,
302
+ };
303
+ }
304
+
305
+ export function evaluatePerfReport(report, thresholds) {
306
+ const failures = [];
307
+ const reportSceneIds = new Set((report.scenes || []).map((scene) => scene.id));
308
+
309
+ if (thresholds.scenePolicy?.requireAllConfiguredScenes === true) {
310
+ for (const sceneId of Object.keys(thresholds.scenes || {})) {
311
+ if (!reportSceneIds.has(sceneId)) {
312
+ failures.push({
313
+ type: 'scene.missing',
314
+ section: 'overall',
315
+ scene: sceneId,
316
+ actual: 'missing',
317
+ threshold: 'present-in-report',
318
+ });
319
+ }
320
+ }
321
+ }
322
+
323
+ for (const scene of report.scenes || []) {
324
+ const overallLimits = thresholds.scenes?.[scene.id];
325
+ if (overallLimits) {
326
+ if (scene.avgFrameTimeMs > overallLimits.maxAvgFrameTimeMs) {
327
+ failures.push({
328
+ type: 'scene.avgFrameTimeMs',
329
+ section: 'overall',
330
+ scene: scene.id,
331
+ actual: scene.avgFrameTimeMs,
332
+ threshold: overallLimits.maxAvgFrameTimeMs,
333
+ });
334
+ }
335
+ if (scene.jitterMs > overallLimits.maxJitterMs) {
336
+ failures.push({
337
+ type: 'scene.jitterMs',
338
+ section: 'overall',
339
+ scene: scene.id,
340
+ actual: scene.jitterMs,
341
+ threshold: overallLimits.maxJitterMs,
342
+ });
343
+ }
344
+ if (scene.avgFps < overallLimits.minFps) {
345
+ failures.push({
346
+ type: 'scene.avgFps',
347
+ section: 'overall',
348
+ scene: scene.id,
349
+ actual: scene.avgFps,
350
+ threshold: overallLimits.minFps,
351
+ });
352
+ }
353
+ if (typeof overallLimits.maxP50FrameTimeMs === 'number' && scene.p50FrameTimeMs > overallLimits.maxP50FrameTimeMs) {
354
+ failures.push({
355
+ type: 'scene.p50FrameTimeMs',
356
+ section: 'overall',
357
+ scene: scene.id,
358
+ actual: scene.p50FrameTimeMs,
359
+ threshold: overallLimits.maxP50FrameTimeMs,
360
+ });
361
+ }
362
+ if (typeof overallLimits.maxP95FrameTimeMs === 'number' && scene.p95FrameTimeMs > overallLimits.maxP95FrameTimeMs) {
363
+ failures.push({
364
+ type: 'scene.p95FrameTimeMs',
365
+ section: 'overall',
366
+ scene: scene.id,
367
+ actual: scene.p95FrameTimeMs,
368
+ threshold: overallLimits.maxP95FrameTimeMs,
369
+ });
370
+ }
371
+ if (typeof overallLimits.maxP99FrameTimeMs === 'number' && scene.p99FrameTimeMs > overallLimits.maxP99FrameTimeMs) {
372
+ failures.push({
373
+ type: 'scene.p99FrameTimeMs',
374
+ section: 'overall',
375
+ scene: scene.id,
376
+ actual: scene.p99FrameTimeMs,
377
+ threshold: overallLimits.maxP99FrameTimeMs,
378
+ });
379
+ }
380
+ if (typeof overallLimits.maxStutterBurstCount === 'number' && scene.stutterBurstCount > overallLimits.maxStutterBurstCount) {
381
+ failures.push({
382
+ type: 'scene.stutterBurstCount',
383
+ section: 'overall',
384
+ scene: scene.id,
385
+ actual: scene.stutterBurstCount,
386
+ threshold: overallLimits.maxStutterBurstCount,
387
+ });
388
+ }
389
+ }
390
+
391
+ pushSliceFailures({
392
+ failures,
393
+ sliceName: 'coldStart',
394
+ slice: scene.coldStart,
395
+ limits: thresholds.coldStart?.scenes?.[scene.id],
396
+ sceneId: scene.id,
397
+ });
398
+ pushSliceFailures({
399
+ failures,
400
+ sliceName: 'warmCache',
401
+ slice: scene.warmCache,
402
+ limits: thresholds.warmCache?.scenes?.[scene.id],
403
+ sceneId: scene.id,
404
+ });
405
+ }
406
+
407
+ if (typeof thresholds.latency?.maxInputQueryMs === 'number'
408
+ && report.latency.inputQueryMs > thresholds.latency.maxInputQueryMs) {
409
+ failures.push({
410
+ type: 'latency.inputQueryMs',
411
+ section: 'latency',
412
+ actual: report.latency.inputQueryMs,
413
+ threshold: thresholds.latency.maxInputQueryMs,
414
+ });
415
+ }
416
+
417
+ if (typeof thresholds.latency?.maxAudioCallMs === 'number'
418
+ && report.latency.audioCallMs > thresholds.latency.maxAudioCallMs) {
419
+ failures.push({
420
+ type: 'latency.audioCallMs',
421
+ section: 'latency',
422
+ actual: report.latency.audioCallMs,
423
+ threshold: thresholds.latency.maxAudioCallMs,
424
+ });
425
+ }
426
+
427
+ if (thresholds.runtimeCacheDiagnostics?.requirePass === true
428
+ && (report.runtimeCacheDiagnostics?.status || 'fail') !== 'pass') {
429
+ failures.push({
430
+ type: 'runtimeCacheDiagnostics',
431
+ section: 'runtimeCacheDiagnostics',
432
+ reasonCode: report.runtimeCacheDiagnostics?.reasonCode || 'runtime_cache_diag_missing',
433
+ actual: report.runtimeCacheDiagnostics?.status || 'missing',
434
+ threshold: 'pass',
435
+ });
436
+ }
437
+
438
+ return failures;
439
+ }
440
+
441
+ export async function runPerfBenchmarks(options = {}) {
442
+ const projectRoot = resolve(options.projectRoot || process.cwd());
443
+ const workspaceRoot = resolve(projectRoot, options.workspaceRoot || DEFAULT_PERF_WORKSPACE_ROOT);
444
+ const reportPath = resolve(projectRoot, options.reportPath || DEFAULT_PERF_REPORT_PATH);
445
+ const scenes = options.scenes || DEFAULT_PERF_SCENES;
446
+ const repeats = Number.isInteger(options.repeats) && options.repeats > 0 ? options.repeats : 5;
447
+ const assetMode = options.assetMode || process.env.AURA_PERF_ASSET_MODE || 'embed';
448
+ const stutterThresholdMs = Number.isFinite(options.stutterThresholdMs)
449
+ ? Number(options.stutterThresholdMs)
450
+ : DEFAULT_STUTTER_THRESHOLD_MS;
451
+
452
+ mkdirSync(resolve(workspaceRoot, 'src'), { recursive: true });
453
+
454
+ const sceneReports = [];
455
+
456
+ for (const scene of scenes) {
457
+ const entryRelative = `src/${scene.id}.js`;
458
+ const entryAbsolute = resolve(workspaceRoot, entryRelative);
459
+ writeFileSync(entryAbsolute, scene.source.trimStart(), 'utf8');
460
+
461
+ const samplesMs = [];
462
+
463
+ for (let run = 0; run < repeats; run += 1) {
464
+ const started = process.hrtime.bigint();
465
+ await runHeadlessTest({
466
+ projectRoot: workspaceRoot,
467
+ file: entryRelative,
468
+ frames: scene.frames,
469
+ width: scene.width ?? 1280,
470
+ height: scene.height ?? 720,
471
+ });
472
+ const ended = process.hrtime.bigint();
473
+ samplesMs.push(Number(ended - started) / 1_000_000);
474
+ }
475
+
476
+ const frameTimes = samplesMs.map((elapsed) => elapsed / scene.frames);
477
+ const coldStartFrameTimes = frameTimes.length > 0 ? [frameTimes[0]] : [];
478
+ const warmCacheFrameTimes = frameTimes.slice(1);
479
+
480
+ const coldStart = buildPerfSlice(coldStartFrameTimes, samplesMs[0] ?? null, { stutterThresholdMs });
481
+ const warmCache = buildPerfSlice(
482
+ warmCacheFrameTimes,
483
+ warmCacheFrameTimes.length > 0 ? mean(samplesMs.slice(1)) : null,
484
+ { stutterThresholdMs },
485
+ );
486
+ const fullSlice = buildPerfSlice(frameTimes, mean(samplesMs), { stutterThresholdMs });
487
+
488
+ sceneReports.push({
489
+ id: scene.id,
490
+ scenario: scene.id,
491
+ assetMode,
492
+ frames: scene.frames,
493
+ repeats,
494
+ frameTimeSamplesMs: frameTimes,
495
+ avgFrameTimeMs: mean(frameTimes),
496
+ medianFrameTimeMs: median(frameTimes),
497
+ p50FrameTimeMs: fullSlice.p50FrameTimeMs,
498
+ p95FrameTimeMs: fullSlice.p95FrameTimeMs,
499
+ p99FrameTimeMs: fullSlice.p99FrameTimeMs,
500
+ jitterMs: stdDev(frameTimes),
501
+ stutterBurstCount: fullSlice.stutterBurstCount,
502
+ stutterThresholdMs,
503
+ avgFps: mean(frameTimes) === 0 ? 0 : 1000 / mean(frameTimes),
504
+ coldStart,
505
+ warmCache,
506
+ });
507
+ }
508
+
509
+ const latency = syntheticLatencies();
510
+ const hostBinary = resolveGateHostBinary({ searchFrom: projectRoot });
511
+
512
+ const report = {
513
+ schemaVersion: PERF_REPORT_SCHEMA,
514
+ generatedAt: new Date().toISOString(),
515
+ environment: {
516
+ node: process.version,
517
+ platform: process.platform,
518
+ arch: process.arch,
519
+ },
520
+ config: {
521
+ repeats,
522
+ sceneCount: scenes.length,
523
+ slices: ['coldStart', 'warmCache'],
524
+ stutterThresholdMs,
525
+ assetMode,
526
+ },
527
+ hostBinary: {
528
+ path: hostBinary.binaryPath,
529
+ packageName: hostBinary.packageName || null,
530
+ source: hostBinary.source || null,
531
+ diagnostics: hostBinary.diagnostics || [],
532
+ target: hostBinary.target || null,
533
+ resolvedAt: hostBinary.resolvedAt || null,
534
+ localBuild: hostBinary.localBuild || null,
535
+ },
536
+ scenes: sceneReports,
537
+ latency,
538
+ runtimeCacheDiagnostics: collectRuntimeCacheDiagnosticsVector(),
539
+ };
540
+
541
+ mkdirSync(dirname(reportPath), { recursive: true });
542
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
543
+
544
+ return {
545
+ ...report,
546
+ reportPath,
547
+ workspaceRoot,
548
+ };
549
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolveAndCacheHostBinary, HostBinaryResolutionError } from './host-binary.mjs';
5
+
6
+ function main() {
7
+ const pkgPath = new URL('../package.json', import.meta.url);
8
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
9
+
10
+ try {
11
+ const result = resolveAndCacheHostBinary({ packageJsonVersion: pkg.version });
12
+ console.log(`[aurajs] host binary ready (${result.target}): ${result.binaryPath}`);
13
+ if (result.cacheStatus !== 'hit') {
14
+ console.log(`[aurajs] cache ${result.cacheStatus}: ${result.cacheDir}`);
15
+ }
16
+ } catch (error) {
17
+ if (error instanceof HostBinaryResolutionError) {
18
+ console.error(`[aurajs] postinstall warning: ${error.message}`);
19
+ console.error('[aurajs] run "npm install" again after optionalDependencies are available.');
20
+ process.exit(0);
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ main();
@@ -0,0 +1,74 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { resolve, join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const TEMPLATES_DIR = resolve(
6
+ dirname(fileURLToPath(import.meta.url)),
7
+ '../templates/starter'
8
+ );
9
+
10
+ /**
11
+ * Scaffold a new AuraJS project.
12
+ * @param {string} name - Project name (used as directory name and in config).
13
+ * @param {string} dest - Absolute path to create the project at.
14
+ */
15
+ export function scaffold(name, dest) {
16
+ if (existsSync(dest)) {
17
+ const entries = readdirSync(dest);
18
+ if (entries.length > 0) {
19
+ throw new Error(`Directory "${name}" already exists and is not empty.`);
20
+ }
21
+ }
22
+
23
+ // Copy template tree, replacing {{PROJECT_NAME}} placeholders.
24
+ copyTree(TEMPLATES_DIR, dest, { PROJECT_NAME: name });
25
+
26
+ // Ensure assets/ directory exists (even if empty in template).
27
+ mkdirSync(join(dest, 'assets'), { recursive: true });
28
+
29
+ return {
30
+ dest,
31
+ files: listFiles(dest),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Recursively copy a directory tree, applying placeholder replacements
37
+ * to text files.
38
+ */
39
+ function copyTree(src, dest, replacements) {
40
+ mkdirSync(dest, { recursive: true });
41
+
42
+ for (const entry of readdirSync(src)) {
43
+ const srcPath = join(src, entry);
44
+ const destPath = join(dest, entry);
45
+ const stat = statSync(srcPath);
46
+
47
+ if (stat.isDirectory()) {
48
+ copyTree(srcPath, destPath, replacements);
49
+ } else {
50
+ let content = readFileSync(srcPath, 'utf8');
51
+ for (const [key, value] of Object.entries(replacements)) {
52
+ content = content.replaceAll(`{{${key}}}`, value);
53
+ }
54
+ writeFileSync(destPath, content, 'utf8');
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * List all files in a directory tree (relative paths).
61
+ */
62
+ function listFiles(dir, prefix = '') {
63
+ const files = [];
64
+ for (const entry of readdirSync(dir)) {
65
+ const full = join(dir, entry);
66
+ const rel = prefix ? `${prefix}/${entry}` : entry;
67
+ if (statSync(full).isDirectory()) {
68
+ files.push(...listFiles(full, rel));
69
+ } else {
70
+ files.push(rel);
71
+ }
72
+ }
73
+ return files;
74
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "identity": {
3
+ "name": "{{PROJECT_NAME}}",
4
+ "version": "0.1.0",
5
+ "executable": "game",
6
+ "icon": null
7
+ },
8
+ "window": {
9
+ "title": "{{PROJECT_NAME}}",
10
+ "width": 1280,
11
+ "height": 720,
12
+ "resizable": true,
13
+ "fullscreen": false,
14
+ "vsync": true,
15
+ "hidpi": true
16
+ },
17
+ "build": {
18
+ "entry": "src/main.js",
19
+ "outDir": "build",
20
+ "assetDir": "assets",
21
+ "assetMode": "embed"
22
+ },
23
+ "modules": {
24
+ "physics": false,
25
+ "network": false,
26
+ "steam": false
27
+ }
28
+ }