@forgeax/engine-app 0.1.3 → 0.1.6

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 (145) hide show
  1. package/README.md +20 -14
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-app-error-retention.integration.test.d.ts +2 -0
  4. package/dist/__tests__/create-app-error-retention.integration.test.d.ts.map +1 -0
  5. package/dist/__tests__/create-app-physics-plugin-failure.integration.test.d.ts +2 -0
  6. package/dist/__tests__/create-app-physics-plugin-failure.integration.test.d.ts.map +1 -0
  7. package/dist/animation-asset-lookup.d.ts +1 -2
  8. package/dist/animation-asset-lookup.d.ts.map +1 -1
  9. package/dist/create-app.d.ts +31 -10
  10. package/dist/create-app.d.ts.map +1 -1
  11. package/dist/engine-worker-runtime.mjs +72 -129
  12. package/dist/engine-worker-runtime.mjs.map +1 -1
  13. package/dist/errors.d.ts +3 -12
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/execution/attached-world-swap.d.ts +1 -12
  16. package/dist/execution/attached-world-swap.d.ts.map +1 -1
  17. package/dist/execution/bootstrap-url.d.ts +5 -0
  18. package/dist/execution/bootstrap-url.d.ts.map +1 -0
  19. package/dist/execution/control.d.ts +0 -2
  20. package/dist/execution/control.d.ts.map +1 -1
  21. package/dist/execution/engine-worker.d.ts +1 -0
  22. package/dist/execution/engine-worker.d.ts.map +1 -1
  23. package/dist/execution/host-controller.d.ts.map +1 -1
  24. package/dist/execution/index.d.ts +1 -1
  25. package/dist/execution/index.d.ts.map +1 -1
  26. package/dist/execution/protocol.d.ts +4 -1
  27. package/dist/execution/protocol.d.ts.map +1 -1
  28. package/dist/execution/types.d.ts +17 -1
  29. package/dist/execution/types.d.ts.map +1 -1
  30. package/dist/game-context.d.ts +1 -1
  31. package/dist/game-context.d.ts.map +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.mjs +332 -366
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/internal/assembled-engine-profile.d.ts.map +1 -1
  37. package/dist/internal/assets-world-plugin.d.ts +3 -17
  38. package/dist/internal/assets-world-plugin.d.ts.map +1 -1
  39. package/dist/internal/browser-remote-bridge.d.ts +1 -2
  40. package/dist/internal/browser-remote-bridge.d.ts.map +1 -1
  41. package/dist/internal/browser-rhi-debug-runtime.d.ts +1 -1
  42. package/dist/internal/browser-rhi-debug-runtime.d.ts.map +1 -1
  43. package/dist/internal/debug-draw.d.ts +9 -0
  44. package/dist/internal/debug-draw.d.ts.map +1 -0
  45. package/dist/internal/engine-profile-common.d.ts +2 -2
  46. package/dist/internal/engine-profile-common.d.ts.map +1 -1
  47. package/dist/internal/frame-loop.d.ts +7 -4
  48. package/dist/internal/frame-loop.d.ts.map +1 -1
  49. package/dist/internal/main-engine-profile.d.ts +4 -0
  50. package/dist/internal/main-engine-profile.d.ts.map +1 -1
  51. package/dist/internal/remote-serve-flag.d.ts +3 -7
  52. package/dist/internal/remote-serve-flag.d.ts.map +1 -1
  53. package/dist/internal/rhi-capture.d.ts +1 -1
  54. package/dist/internal/rhi-capture.d.ts.map +1 -1
  55. package/dist/internal/worker-engine-profile.d.ts +2 -0
  56. package/dist/internal/worker-engine-profile.d.ts.map +1 -1
  57. package/dist/renderer-plugin.d.ts +25 -0
  58. package/dist/renderer-plugin.d.ts.map +1 -0
  59. package/dist/tool-preview/bootstrap.d.ts.map +1 -1
  60. package/dist/types.d.ts +21 -61
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +25 -26
  63. package/src/__tests__/callback-inference.test-d.ts +7 -3
  64. package/src/__tests__/consumer-migration.unit.test.ts +13 -0
  65. package/src/__tests__/create-app-error-retention.integration.test.ts +125 -0
  66. package/src/__tests__/create-app-features.test.ts +21 -10
  67. package/src/__tests__/create-app-listener-sync.test.ts +23 -19
  68. package/src/__tests__/create-app-physics-plugin-failure.integration.test.ts +142 -0
  69. package/src/__tests__/create-app-stop.test.ts +4 -12
  70. package/src/__tests__/create-app.test-d.ts +12 -11
  71. package/src/__tests__/create-app.test.ts +1 -60
  72. package/src/__tests__/create-renderer-error.integration.test.ts +3 -2
  73. package/src/__tests__/create-renderer-lifecycle.integration.test.ts +10 -5
  74. package/src/__tests__/draw-source-injected-world-update-visibility.test.ts +7 -13
  75. package/src/__tests__/draw-source-single-world-regression.test.ts +23 -24
  76. package/src/__tests__/engine-worker-bootstrap-channel.unit.test.ts +8 -0
  77. package/src/__tests__/engine-worker-world-swap.unit.test.ts +15 -22
  78. package/src/__tests__/errors-pointer-lock-failed.test.ts +4 -7
  79. package/src/__tests__/execution-bootstrap-isolation.unit.test.ts +5 -6
  80. package/src/__tests__/execution-frame-order.unit.test.ts +2 -2
  81. package/src/__tests__/execution-frame-protocol.unit.test.ts +3 -4
  82. package/src/__tests__/execution-public-api.test-d.ts +4 -0
  83. package/src/__tests__/frame-loop-time-elapsed.test.ts +1 -1
  84. package/src/__tests__/frame-loop-time.unit.test.ts +1 -1
  85. package/src/__tests__/frame-loop-world-array.test.ts +82 -73
  86. package/src/__tests__/plugin-profiles.unit.test.ts +3 -3
  87. package/src/__tests__/profiler-default-off.test.ts +1 -1
  88. package/src/__tests__/profiler-frame-token.test.ts +17 -30
  89. package/src/__tests__/profiler-phase-integration.test.ts +15 -27
  90. package/src/__tests__/surface-handoff.test.ts +1 -4
  91. package/src/__tests__/visibility-remote.integration.test.ts +2 -5
  92. package/src/__tests__/worker-execution-stop.test.ts +64 -7
  93. package/src/animation-asset-lookup.ts +2 -2
  94. package/src/create-app.ts +197 -296
  95. package/src/errors.ts +27 -39
  96. package/src/execution/attached-world-swap.ts +4 -38
  97. package/src/execution/bootstrap-url.ts +25 -0
  98. package/src/execution/control.ts +3 -7
  99. package/src/execution/engine-worker-runtime.ts +23 -40
  100. package/src/execution/engine-worker.ts +2 -0
  101. package/src/execution/host-controller.ts +8 -2
  102. package/src/execution/index.ts +1 -0
  103. package/src/execution/protocol.ts +4 -1
  104. package/src/execution/types.ts +18 -1
  105. package/src/game-context.ts +1 -1
  106. package/src/index.ts +11 -4
  107. package/src/internal/assembled-engine-profile.ts +3 -11
  108. package/src/internal/assets-world-plugin.ts +7 -53
  109. package/src/internal/browser-remote-bridge.ts +12 -10
  110. package/src/internal/browser-rhi-debug-runtime.ts +1 -1
  111. package/src/internal/debug-draw.ts +38 -0
  112. package/src/internal/engine-profile-common.ts +2 -3
  113. package/src/internal/frame-loop.ts +77 -63
  114. package/src/internal/main-engine-profile.ts +21 -17
  115. package/src/internal/remote-serve-flag.ts +5 -14
  116. package/src/internal/rhi-capture.ts +1 -1
  117. package/src/internal/worker-engine-profile.ts +10 -10
  118. package/src/renderer-plugin.ts +96 -0
  119. package/src/tool-preview/bootstrap.ts +1 -14
  120. package/src/types.ts +22 -77
  121. package/src/vite-env.d.ts +0 -2
  122. package/dist/__tests__/app-rebuild.test.d.ts +0 -2
  123. package/dist/__tests__/app-rebuild.test.d.ts.map +0 -1
  124. package/dist/__tests__/asset-registry-types.test-d.d.ts +0 -2
  125. package/dist/__tests__/asset-registry-types.test-d.d.ts.map +0 -1
  126. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts +0 -2
  127. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts.map +0 -1
  128. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts +0 -2
  129. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts.map +0 -1
  130. package/dist/__tests__/ecs-import.unit.test.d.ts +0 -2
  131. package/dist/__tests__/ecs-import.unit.test.d.ts.map +0 -1
  132. package/dist/__tests__/execution-world-health-owner.test-d.d.ts +0 -2
  133. package/dist/__tests__/execution-world-health-owner.test-d.d.ts.map +0 -1
  134. package/dist/__tests__/remote-serve-flag.test.d.ts +0 -2
  135. package/dist/__tests__/remote-serve-flag.test.d.ts.map +0 -1
  136. package/dist/assets-runtime-assembly.d.ts +0 -19
  137. package/dist/assets-runtime-assembly.d.ts.map +0 -1
  138. package/src/__tests__/app-rebuild.test.ts +0 -31
  139. package/src/__tests__/asset-registry-types.test-d.ts +0 -16
  140. package/src/__tests__/assets-runtime-assembly.integration.test.ts +0 -68
  141. package/src/__tests__/draw-source-headless-full-chain.test.ts +0 -226
  142. package/src/__tests__/ecs-import.unit.test.ts +0 -40
  143. package/src/__tests__/execution-world-health-owner.test-d.ts +0 -9
  144. package/src/__tests__/remote-serve-flag.test.ts +0 -24
  145. package/src/assets-runtime-assembly.ts +0 -70
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { Time, Update, World } from '@forgeax/engine-ecs';
2
4
  import { describe, expect, it } from 'vitest';
3
5
 
@@ -18,4 +20,15 @@ describe('callback consumer migration', () => {
18
20
  world.update(1 / 60).unwrap();
19
21
  expect(observed).toBeCloseTo(1 / 60);
20
22
  });
23
+
24
+ it('uses the receipt-bound renderer host contract', () => {
25
+ const source = [
26
+ readFileSync(resolve(process.cwd(), 'packages/app/src/create-app.ts'), 'utf8'),
27
+ readFileSync(resolve(process.cwd(), 'packages/app/src/internal/frame-loop.ts'), 'utf8'),
28
+ ].join('\n');
29
+ expect(source).toContain('renderer.attach(');
30
+ expect(source).toContain('renderer.draw({');
31
+ expect(source).not.toContain('renderer.ready');
32
+ expect(source).not.toContain('renderer.device');
33
+ });
21
34
  });
@@ -0,0 +1,125 @@
1
+ import { Update, World } from '@forgeax/engine-ecs';
2
+ import type { Renderer } from '@forgeax/engine-render';
3
+ import { RhiError } from '@forgeax/engine-rhi';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { createApp } from '../create-app';
6
+ import type { AppDispatchError } from '../types';
7
+
8
+ function makeRenderer(): {
9
+ readonly renderer: Renderer;
10
+ readonly draw: ReturnType<typeof vi.fn>;
11
+ readonly emit: (error: RhiError) => void;
12
+ } {
13
+ const draw = vi.fn(() => ({ ok: true as const, value: undefined }));
14
+ let listener: ((event: { readonly kind: 'error'; readonly error: RhiError }) => void) | undefined;
15
+ const renderer = {
16
+ backend: 'webgpu' as const,
17
+ ready: Promise.resolve({ ok: true as const, value: undefined }),
18
+ draw,
19
+ attach: () => ({ ok: true as const, value: { dispose: () => undefined } }),
20
+ attachWorld: () => ({ ok: true as const, value: undefined }),
21
+ detachWorld: () => undefined,
22
+ subscribe: (next: (event: { readonly kind: 'error'; readonly error: RhiError }) => void) => {
23
+ listener = next;
24
+ return () => {
25
+ if (listener === next) listener = undefined;
26
+ };
27
+ },
28
+ onLost: () => () => undefined,
29
+ dispose: () => undefined,
30
+ } as unknown as Renderer;
31
+ return { renderer, draw, emit: (error) => listener?.({ kind: 'error', error }) };
32
+ }
33
+
34
+ function installFaultingSystem(world: World, cause: Error): void {
35
+ world
36
+ .addSystem(Update, {
37
+ name: 'retention-faulting-update',
38
+ queries: [],
39
+ fn: () => {
40
+ throw cause;
41
+ },
42
+ })
43
+ .unwrap();
44
+ }
45
+
46
+ async function createPausedApp(world: World, renderer: Renderer, silenceUnhandledErrors = true) {
47
+ const result = await createApp({ renderer, world, silenceUnhandledErrors });
48
+ expect(result.ok).toBe(true);
49
+ if (!result.ok) throw result.error;
50
+ result.value.start().unwrap();
51
+ result.value.pause().unwrap();
52
+ return result.value;
53
+ }
54
+
55
+ describe('createApp error retention', () => {
56
+ it('retains a real frame/update system failure through the main dispatch owner', async () => {
57
+ const cause = new Error('boom');
58
+ const world = new World();
59
+ installFaultingSystem(world, cause);
60
+ const { renderer, draw } = makeRenderer();
61
+ const app = await createPausedApp(world, renderer);
62
+ let observed: AppDispatchError | undefined;
63
+ app.onError((error) => {
64
+ observed = error;
65
+ });
66
+
67
+ const frame = app.stepFrame(1 / 60);
68
+
69
+ expect(frame.ok).toBe(false);
70
+ if (frame.ok) return;
71
+ expect(frame.error.code).toBe('app-system-update-failed');
72
+ if (frame.error.code !== 'app-system-update-failed') return;
73
+ const updateError = frame.error.detail.cause as {
74
+ readonly code?: string;
75
+ readonly detail?: { readonly cause?: unknown };
76
+ };
77
+ expect(updateError.code).toBe('system-failed');
78
+ expect(updateError.detail?.cause).toBe(cause);
79
+ expect(observed).toBe(frame.error);
80
+ expect(app.lastError).toBe(frame.error);
81
+ expect(world.execution.health).toBe('poisoned');
82
+ expect(world.inspect().systems.map((system) => system.name)).toContain(
83
+ 'retention-faulting-update',
84
+ );
85
+ expect(draw).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('retains the same structured failure when console fallback is the only observer', async () => {
89
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
90
+ const world = new World();
91
+ installFaultingSystem(world, new Error('unobserved boom'));
92
+ const { renderer } = makeRenderer();
93
+ const app = await createPausedApp(world, renderer, false);
94
+
95
+ const frame = app.stepFrame(1 / 60);
96
+
97
+ expect(frame.ok).toBe(false);
98
+ if (!frame.ok) {
99
+ expect(consoleError).toHaveBeenCalledTimes(1);
100
+ expect(consoleError).toHaveBeenCalledWith(frame.error);
101
+ expect(app.lastError).toBe(frame.error);
102
+ }
103
+ consoleError.mockRestore();
104
+ });
105
+
106
+ it('preserves renderer device-loss identity and retention', async () => {
107
+ const world = new World();
108
+ const { renderer, emit } = makeRenderer();
109
+ const app = await createPausedApp(world, renderer);
110
+ let observed: AppDispatchError | undefined;
111
+ app.onError((error) => {
112
+ observed = error;
113
+ });
114
+ const deviceLoss = new RhiError({
115
+ code: 'device-lost',
116
+ expected: 'the test device remains available',
117
+ hint: 'rebuild the renderer after device recovery',
118
+ });
119
+
120
+ emit(deviceLoss);
121
+
122
+ expect(observed).toBe(deviceLoss);
123
+ expect(app.lastError).toBe(deviceLoss);
124
+ });
125
+ });
@@ -1,23 +1,31 @@
1
1
  import type { RenderFeature } from '@forgeax/engine-render';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
 
4
- const createRenderer = vi.hoisted(() => vi.fn());
4
+ const constructRuntimeRendererHost = vi.hoisted(() => vi.fn());
5
5
 
6
6
  vi.mock('@forgeax/engine-runtime', async (importOriginal) => {
7
7
  const actual = await importOriginal<typeof import('@forgeax/engine-runtime')>();
8
- return { ...actual, createRenderer };
8
+ return actual;
9
9
  });
10
10
 
11
+ vi.mock('@forgeax/engine-runtime/internal/renderer-host', () => ({
12
+ constructRuntimeRendererHost,
13
+ loadRhiPack: vi.fn(),
14
+ }));
15
+
11
16
  import { createApp } from '../create-app';
12
17
 
13
18
  function rendererStub() {
19
+ const lease = {};
14
20
  return {
15
- backend: 'webgpu' as const,
16
- ready: Promise.resolve({ ok: true as const, value: undefined }),
17
- draw: () => ({ ok: true as const, value: undefined }),
21
+ attach: () => ({ ok: true as const, value: lease }),
22
+ draw: () => ({ ok: true as const, value: {} }),
23
+ observe: async () => ({ ok: true as const, value: {} }),
24
+ releaseSurface: () => ({ ok: true as const, value: undefined }),
25
+ restoreSurface: () => ({ ok: true as const, value: undefined }),
26
+ recover: async () => ({ ok: true as const, value: undefined }),
18
27
  onError: () => () => undefined,
19
28
  onLost: () => () => undefined,
20
- onHealthChange: () => () => undefined,
21
29
  dispose: () => undefined,
22
30
  };
23
31
  }
@@ -27,14 +35,17 @@ describe('createApp features passthrough', () => {
27
35
  const first = { identity: 'test.first' } as unknown as RenderFeature<unknown>;
28
36
  const second = { identity: 'test.second' } as unknown as RenderFeature<unknown>;
29
37
  const features = [first, second] as const;
30
- createRenderer.mockResolvedValue(rendererStub());
38
+ constructRuntimeRendererHost.mockResolvedValue({
39
+ ok: true,
40
+ value: { renderer: rendererStub(), assets: undefined },
41
+ });
31
42
 
32
43
  const canvas = { tagName: 'canvas', isConnected: true } as HTMLCanvasElement;
33
44
  const result = await createApp(canvas, { features });
34
45
 
35
46
  expect(result.ok).toBe(true);
36
- expect(createRenderer).toHaveBeenCalledTimes(1);
37
- expect(createRenderer.mock.calls[0]?.[1]).toMatchObject({ features });
38
- expect(createRenderer.mock.calls[0]?.[1].features).toBe(features);
47
+ expect(constructRuntimeRendererHost).toHaveBeenCalledTimes(1);
48
+ expect(constructRuntimeRendererHost.mock.calls[0]?.[1]).toMatchObject({ features });
49
+ expect(constructRuntimeRendererHost.mock.calls[0]?.[1].features).toBe(features);
39
50
  });
40
51
  });
@@ -6,7 +6,7 @@
6
6
  // test used the assemble form with a hand-copied registerListenerSyncSystem
7
7
  // helper — a shadow copy of the production code that left w25 untested.
8
8
  //
9
- // Approach: vi.mock createRenderer (only export overridden) so the canvas
9
+ // Approach: vi.mock constructRuntimeRendererHost (only construction overridden) so the canvas
10
10
  // form does not attempt real WebGPU init; all other exports from
11
11
  // @forgeax/engine-runtime remain real (Transform, registerPropagateTransforms,
12
12
  // PROPAGATE_TRANSFORMS_SYSTEM, etc.). globalThis.AudioContext is stubbed
@@ -34,43 +34,47 @@ import { Transform } from '@forgeax/engine-scene';
34
34
  import { describe, expect, it, vi } from 'vitest';
35
35
  import { createApp } from '../create-app';
36
36
 
37
+ const constructRuntimeRendererHost = vi.hoisted(() => vi.fn());
38
+
37
39
  // ---------------------------------------------------------------------------
38
- // Mock createRenderer so the canvas form does not attempt real WebGPU init.
40
+ // Mock constructRuntimeRendererHost so the canvas form does not attempt real WebGPU init.
39
41
  // All other @forgeax/engine-runtime exports stay real (Transform,
40
42
  // registerPropagateTransforms, advanceAnimationPlayer, etc.).
41
43
  // ---------------------------------------------------------------------------
42
44
 
43
45
  vi.mock('@forgeax/engine-runtime', async (importOriginal) => {
44
46
  const actual = await importOriginal<typeof import('@forgeax/engine-runtime')>();
45
- const ready: Promise<{ ok: true; value: undefined }> = Promise.resolve({
46
- ok: true,
47
- value: undefined,
48
- });
47
+ const lease = { dispose: vi.fn() };
49
48
  const rendererStub = {
50
- backend: 'webgpu' as const,
51
- ready,
52
- draw(): { ok: true; value: undefined } {
53
- return { ok: true, value: undefined };
54
- },
55
- attachWorld(): { ok: true; value: undefined } {
56
- return { ok: true, value: undefined };
49
+ attach(): { ok: true; value: object } {
50
+ return { ok: true, value: lease };
57
51
  },
58
- detachWorld(): void {},
59
- onError(): () => void {
60
- return () => {};
52
+ draw(): { ok: true; value: object } {
53
+ return { ok: true, value: {} };
61
54
  },
62
- onLost(): () => void {
55
+ observe: async () => ({ ok: true as const, value: {} }),
56
+ releaseSurface: () => ({ ok: true as const, value: undefined }),
57
+ restoreSurface: () => ({ ok: true as const, value: undefined }),
58
+ recover: async () => ({ ok: true as const, value: undefined }),
59
+ subscribe(): () => void {
63
60
  return () => {};
64
61
  },
65
62
  dispose: vi.fn(),
66
- assets: null,
67
63
  };
64
+ constructRuntimeRendererHost.mockResolvedValue({
65
+ ok: true,
66
+ value: { renderer: rendererStub, assets: undefined },
67
+ });
68
68
  return {
69
69
  ...actual,
70
- createRenderer: vi.fn().mockResolvedValue(rendererStub),
71
70
  };
72
71
  });
73
72
 
73
+ vi.mock('@forgeax/engine-runtime/internal/renderer-host', () => ({
74
+ constructRuntimeRendererHost,
75
+ loadRhiPack: vi.fn(),
76
+ }));
77
+
74
78
  // ---------------------------------------------------------------------------
75
79
  // Test helpers
76
80
  // ---------------------------------------------------------------------------
@@ -0,0 +1,142 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { PhysicsError, physicsPlugin } from '@forgeax/engine-physics';
3
+ import type { Renderer } from '@forgeax/engine-render';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { createApp } from '../create-app';
6
+
7
+ const rapier3d = vi.hoisted(() => ({
8
+ load: vi.fn(),
9
+ createWorld: vi.fn(),
10
+ registerSystems: vi.fn(),
11
+ }));
12
+
13
+ const rapier2d = vi.hoisted(() => ({
14
+ load: vi.fn(),
15
+ createWorld: vi.fn(),
16
+ registerSystems: vi.fn(),
17
+ }));
18
+
19
+ vi.mock('@forgeax/engine-physics-rapier3d', () => ({
20
+ loadRapier3D: rapier3d.load,
21
+ createRapier3DPhysicsWorld: rapier3d.createWorld,
22
+ registerPhysicsSystems: rapier3d.registerSystems,
23
+ }));
24
+
25
+ vi.mock('@forgeax/engine-physics-rapier2d', () => ({
26
+ loadRapier2D: rapier2d.load,
27
+ createRapier2DPhysicsWorld: rapier2d.createWorld,
28
+ registerPhysicsSystems2D: rapier2d.registerSystems,
29
+ }));
30
+
31
+ function rendererStub(): Renderer {
32
+ return {
33
+ backend: 'webgpu',
34
+ ready: Promise.resolve({ ok: true, value: undefined }),
35
+ draw: () => ({ ok: true, value: undefined }),
36
+ onError: () => () => {},
37
+ onLost: () => () => {},
38
+ assets: {},
39
+ dispose: () => {},
40
+ } as unknown as Renderer;
41
+ }
42
+
43
+ function injectedWasmFailure(backend: '2D' | '3D', reason: string): PhysicsError {
44
+ return new PhysicsError({
45
+ code: 'wasm-load-failed',
46
+ expected: `the injected Rapier ${backend} loader to become ready`,
47
+ hint: `injected failure: ${reason}`,
48
+ detail: { code: 'wasm-load-failed', reason },
49
+ });
50
+ }
51
+
52
+ describe('createApp Rapier 3D activation failure', () => {
53
+ beforeEach(() => {
54
+ rapier3d.load.mockReset();
55
+ rapier3d.createWorld.mockReset();
56
+ rapier3d.registerSystems.mockReset();
57
+ });
58
+
59
+ it.each([
60
+ {
61
+ phase: 'loader return',
62
+ inject: () => rapier3d.load.mockResolvedValue(injectedWasmFailure('3D', 'loader return')),
63
+ },
64
+ {
65
+ phase: 'compat import',
66
+ inject: () => rapier3d.load.mockRejectedValue(new Error('injected compat import rejection')),
67
+ },
68
+ {
69
+ phase: 'WASM init',
70
+ inject: () => rapier3d.load.mockRejectedValue(new Error('injected WASM init rejection')),
71
+ },
72
+ ])('normalizes $phase before PhysicsWorld creation', async ({ inject }) => {
73
+ inject();
74
+ const world = new World();
75
+
76
+ const result = await createApp({
77
+ renderer: rendererStub(),
78
+ world,
79
+ plugins: [physicsPlugin('rapier-3d')],
80
+ });
81
+
82
+ expect(result.ok).toBe(false);
83
+ if (result.ok) return;
84
+ expect(result.error.code).toBe('app-plugin-activation-failed');
85
+ if (result.error.code !== 'app-plugin-activation-failed') return;
86
+ expect(result.error.detail.cause).toMatchObject({
87
+ name: 'PhysicsError',
88
+ code: 'wasm-load-failed',
89
+ detail: { code: 'wasm-load-failed' },
90
+ });
91
+ expect(rapier3d.createWorld).not.toHaveBeenCalled();
92
+ expect(rapier3d.registerSystems).not.toHaveBeenCalled();
93
+ expect(world.hasResource('PhysicsWorld')).toBe(false);
94
+ expect(world.update(0)).toMatchObject({ ok: true });
95
+ });
96
+ });
97
+
98
+ describe('createApp Rapier 2D activation failure', () => {
99
+ beforeEach(() => {
100
+ rapier2d.load.mockReset();
101
+ rapier2d.createWorld.mockReset();
102
+ rapier2d.registerSystems.mockReset();
103
+ });
104
+
105
+ it.each([
106
+ {
107
+ phase: 'loader return',
108
+ inject: () => rapier2d.load.mockResolvedValue(injectedWasmFailure('2D', 'loader return')),
109
+ },
110
+ {
111
+ phase: 'compat import',
112
+ inject: () => rapier2d.load.mockRejectedValue(new Error('injected compat import rejection')),
113
+ },
114
+ {
115
+ phase: 'WASM init',
116
+ inject: () => rapier2d.load.mockRejectedValue(new Error('injected WASM init rejection')),
117
+ },
118
+ ])('normalizes $phase before PhysicsWorld creation', async ({ inject }) => {
119
+ inject();
120
+ const world = new World();
121
+
122
+ const result = await createApp({
123
+ renderer: rendererStub(),
124
+ world,
125
+ plugins: [physicsPlugin('rapier-2d')],
126
+ });
127
+
128
+ expect(result.ok).toBe(false);
129
+ if (result.ok) return;
130
+ expect(result.error.code).toBe('app-plugin-activation-failed');
131
+ if (result.error.code !== 'app-plugin-activation-failed') return;
132
+ expect(result.error.detail.cause).toMatchObject({
133
+ name: 'PhysicsError',
134
+ code: 'wasm-load-failed',
135
+ detail: { code: 'wasm-load-failed' },
136
+ });
137
+ expect(rapier2d.createWorld).not.toHaveBeenCalled();
138
+ expect(rapier2d.registerSystems).not.toHaveBeenCalled();
139
+ expect(world.hasResource('PhysicsWorld')).toBe(false);
140
+ expect(world.update(0)).toMatchObject({ ok: true });
141
+ });
142
+ });
@@ -15,6 +15,7 @@ import type { Renderer } from '@forgeax/engine-render';
15
15
  import type { RhiError } from '@forgeax/engine-rhi/errors';
16
16
  import { ok } from '@forgeax/engine-types';
17
17
  import { describe, expect, it, vi } from 'vitest';
18
+ import { RendererContractFailureError } from '../../../render/src/errors/render';
18
19
 
19
20
  import { createApp } from '../create-app';
20
21
 
@@ -38,16 +39,11 @@ function makeRendererStubForStop(): StubRenderer {
38
39
  draw(): { ok: true; value: undefined } {
39
40
  return { ok: true, value: undefined };
40
41
  },
41
- attachWorld(): { ok: true; value: undefined } {
42
+ attach(): { ok: true; value: undefined } {
42
43
  return { ok: true, value: undefined };
43
44
  },
44
45
  detachWorld(): void {},
45
- onError(): () => void {
46
- return () => {
47
- // no-op unsubscribe
48
- };
49
- },
50
- onLost(): () => void {
46
+ subscribe(): () => void {
51
47
  return () => {
52
48
  // no-op unsubscribe
53
49
  };
@@ -83,11 +79,7 @@ describe('create-app-stop.test.ts', () => {
83
79
 
84
80
  it('does not restore a running loop when surface release fails', async () => {
85
81
  const { renderer } = makeRendererStubForStop();
86
- const releaseError = new (await import('@forgeax/engine-rhi')).RhiError({
87
- code: 'rhi-not-available',
88
- expected: 'test release succeeds',
89
- hint: 'forced failure',
90
- });
82
+ const releaseError = new RendererContractFailureError('draw', 'forced failure');
91
83
  vi.mocked(renderer.releaseSurface).mockReturnValue({ ok: false, error: releaseError });
92
84
  const result = await createApp({ renderer, world: new World() });
93
85
  expect(result.ok).toBe(true);
@@ -18,21 +18,19 @@
18
18
  // - P4 consistent abstraction: both routes return the same App handle.
19
19
 
20
20
  import type { World } from '@forgeax/engine-ecs';
21
- import type { Renderer, RendererOptions } from '@forgeax/engine-render';
21
+ import type { RenderError, Renderer, RendererOptions } from '@forgeax/engine-render';
22
22
  import type { RhiError } from '@forgeax/engine-rhi/errors';
23
- import type { EngineEnvironmentError } from '@forgeax/engine-runtime';
23
+ import { createRenderer, type EngineEnvironmentError } from '@forgeax/engine-runtime';
24
24
  import type { Result } from '@forgeax/engine-types';
25
25
  import { describe, expectTypeOf, it } from 'vitest';
26
- import type { AssetRuntimeAssemblyError } from '../assets-runtime-assembly';
27
26
  import { type App, type AppError, createApp } from '../index';
28
- import type { RhiCaptureHook } from '../types';
29
27
 
30
28
  describe('createApp double-SSOT entry signatures (w2 acceptanceCheck)', () => {
31
29
  it('canvas form returns the App boundary error union', () => {
32
30
  const canvas = null as unknown as HTMLCanvasElement;
33
31
  const ret = createApp(canvas);
34
32
  expectTypeOf(ret).toEqualTypeOf<
35
- Promise<Result<App, AppError | RhiError | AssetRuntimeAssemblyError | EngineEnvironmentError>>
33
+ Promise<Result<App, AppError | RhiError | EngineEnvironmentError>>
36
34
  >();
37
35
  });
38
36
 
@@ -40,9 +38,7 @@ describe('createApp double-SSOT entry signatures (w2 acceptanceCheck)', () => {
40
38
  const renderer = null as unknown as Renderer;
41
39
  const world = null as unknown as World;
42
40
  const ret = createApp({ renderer, world });
43
- expectTypeOf(ret).toEqualTypeOf<
44
- Promise<Result<App, AppError | RhiError | AssetRuntimeAssemblyError>>
45
- >();
41
+ expectTypeOf(ret).toEqualTypeOf<Promise<Result<App, AppError | RhiError>>>();
46
42
  });
47
43
 
48
44
  it('app.renderer / app.world are reference-equal types at the call site (AC-09)', async () => {
@@ -54,10 +50,15 @@ describe('createApp double-SSOT entry signatures (w2 acceptanceCheck)', () => {
54
50
  expectTypeOf(result.value.world).toEqualTypeOf<World>();
55
51
  }
56
52
  });
53
+ });
57
54
 
58
- it('main and Worker execution share the same global capture hook shape', () => {
59
- const captureFrame = (globalThis as { __forgeax?: RhiCaptureHook }).__forgeax?.captureFrame;
60
- expectTypeOf(captureFrame).toEqualTypeOf<RhiCaptureHook['captureFrame'] | undefined>();
55
+ describe('Runtime createRenderer Result boundary (AC-11)', () => {
56
+ it('requires callers to narrow the construction Result', () => {
57
+ const canvas = null as unknown as HTMLCanvasElement;
58
+ const expected = null as unknown as Promise<
59
+ Result<Renderer, EngineEnvironmentError | RenderError>
60
+ >;
61
+ expectTypeOf(createRenderer(canvas)).toEqualTypeOf(expected);
61
62
  });
62
63
  });
63
64
 
@@ -25,8 +25,7 @@ import { World } from '@forgeax/engine-ecs';
25
25
  import { Camera, orthographic, perspective } from '@forgeax/engine-render';
26
26
  import { describe, expect, it } from 'vitest';
27
27
 
28
- import { createApp, syncCameraAspect, syncCanvasDrawingBuffer } from '../create-app';
29
- import { AppError } from '../errors';
28
+ import { syncCameraAspect, syncCanvasDrawingBuffer } from '../create-app';
30
29
  import { resolveRhiDebugFlag } from '../internal/rhi-debug-flag';
31
30
 
32
31
  describe('create-app.test.ts', () => {
@@ -119,64 +118,6 @@ describe('create-app.test.ts', () => {
119
118
  }
120
119
  });
121
120
 
122
- it('applies a Host pixel-ratio cap without changing the default path', () => {
123
- const canvas = {
124
- clientWidth: 800,
125
- clientHeight: 400,
126
- width: 300,
127
- height: 150,
128
- style: { width: '800px', height: '400px' },
129
- };
130
- const before = globalThis.devicePixelRatio;
131
- Object.defineProperty(globalThis, 'devicePixelRatio', { configurable: true, value: 2 });
132
- try {
133
- syncCanvasDrawingBuffer(canvas, 1.5);
134
- expect(canvas.width).toBe(1200);
135
- expect(canvas.height).toBe(600);
136
- } finally {
137
- Object.defineProperty(globalThis, 'devicePixelRatio', {
138
- configurable: true,
139
- value: before,
140
- });
141
- }
142
- });
143
-
144
- it('allows the Host to downsample a GPU-bound canvas below CSS pixel density', () => {
145
- const canvas = {
146
- clientWidth: 800,
147
- clientHeight: 400,
148
- width: 300,
149
- height: 150,
150
- style: { width: '800px', height: '400px' },
151
- };
152
- const before = globalThis.devicePixelRatio;
153
- Object.defineProperty(globalThis, 'devicePixelRatio', { configurable: true, value: 2 });
154
- try {
155
- syncCanvasDrawingBuffer(canvas, 0.875);
156
- expect(canvas.width).toBe(700);
157
- expect(canvas.height).toBe(350);
158
- } finally {
159
- Object.defineProperty(globalThis, 'devicePixelRatio', {
160
- configurable: true,
161
- value: before,
162
- });
163
- }
164
- });
165
-
166
- it('rejects an invalid Host pixel-ratio cap before touching a detached canvas', async () => {
167
- const result = await createApp(
168
- { tagName: 'CANVAS', isConnected: false } as unknown as HTMLCanvasElement,
169
- { maxCanvasPixelRatio: 0 },
170
- );
171
- expect(result.ok).toBe(false);
172
- if (!result.ok && result.error instanceof AppError) {
173
- expect(result.error.code).toBe('app-invalid-canvas-pixel-ratio');
174
- if (result.error.code === 'app-invalid-canvas-pixel-ratio') {
175
- expect(result.error.detail.value).toBe(0);
176
- }
177
- }
178
- });
179
-
180
121
  it('leaves a zero-sized host untouched', () => {
181
122
  const canvas = { clientWidth: 0, clientHeight: 400, width: 300, height: 150 };
182
123
  syncCanvasDrawingBuffer(canvas);
@@ -3,8 +3,9 @@ import { describe, expect, it } from 'vitest';
3
3
  import { createApp } from '../create-app';
4
4
 
5
5
  describe('app renderer construction boundary', () => {
6
- it('keeps construction failures catchable', async () => {
7
- await expect(createRenderer(null as never)).rejects.toBeDefined();
6
+ it('returns construction failures through the Runtime Result boundary', async () => {
7
+ const result = await createRenderer(null as never);
8
+ expect(result.ok).toBe(false);
8
9
  });
9
10
 
10
11
  it('returns EngineEnvironmentError through the canvas Result path', async () => {
@@ -5,13 +5,18 @@ describe('app-facing renderer lifecycle', () => {
5
5
  it('consumes the three lifecycle Result boundaries without casts', async () => {
6
6
  const events: string[] = [];
7
7
  const renderer = {
8
- ready: Promise.resolve({ ok: true as const, value: undefined }),
9
- draw: () => ({ ok: true as const, value: undefined }),
8
+ attach: () => ({ ok: false as const, error: {} }),
9
+ draw: () => ({ ok: false as const, error: {} }),
10
+ observe: async () => ({ ok: false as const, error: {} }),
11
+ releaseSurface: () => ({ ok: true as const, value: undefined }),
12
+ restoreSurface: () => ({ ok: true as const, value: undefined }),
13
+ recover: async () => ({ ok: true as const, value: undefined }),
10
14
  dispose: () => events.push('dispose'),
15
+ onError: () => () => undefined,
16
+ onLost: () => () => undefined,
11
17
  } as unknown as Renderer;
12
- const ready = await renderer.ready;
13
- expect(ready.ok).toBe(true);
14
- expect(renderer.draw([], { cameraOwner: 0, resourceOwner: 0 }).ok).toBe(true);
18
+ expect(renderer.attach({} as never).ok).toBe(false);
19
+ expect(renderer.draw({} as never).ok).toBe(false);
15
20
  renderer.dispose();
16
21
  renderer.dispose();
17
22
  expect(events).toEqual(['dispose', 'dispose']);
@@ -34,6 +34,7 @@
34
34
 
35
35
  import type { EntityHandle } from '@forgeax/engine-ecs';
36
36
  import { World } from '@forgeax/engine-ecs';
37
+ import { createRenderReadLease } from '@forgeax/engine-ecs/projection';
37
38
  import type { Renderer } from '@forgeax/engine-render';
38
39
  import { registerPropagateTransforms, Transform } from '@forgeax/engine-scene';
39
40
  import { describe, expect, it } from 'vitest';
@@ -123,19 +124,12 @@ describe('drawSource injected worlds are updated same-frame (w9, stale-matrix re
123
124
  // stage performs (Transform.world is the extract SSOT).
124
125
  const observed: Array<[number, number, number]> = [];
125
126
  const renderer = {
126
- backend: 'webgpu' as const,
127
- ready: Promise.resolve({ ok: true, value: undefined }),
128
- attachWorld: () => ({ ok: true, value: undefined }),
129
- detachWorld: () => {},
130
- draw(worlds: readonly World[]): { ok: true; value: undefined } {
131
- // worlds is what drawSource returned; find the injected world in it.
132
- const iw = worlds.find((w) => w === injectedWorld);
133
- if (iw !== undefined) {
134
- const r = iw.get(entity, Transform);
135
- if (r.ok) {
136
- const m = r.value.world as unknown as ArrayLike<number>;
137
- observed.push([m[12] as number, m[13] as number, m[14] as number]);
138
- }
127
+ attach: (world: World) => ({ ok: true, value: createRenderReadLease(world) }),
128
+ draw(): { ok: true; value: undefined } {
129
+ const r = injectedWorld.get(entity, Transform);
130
+ if (r.ok) {
131
+ const m = r.value.world as unknown as ArrayLike<number>;
132
+ observed.push([m[12] as number, m[13] as number, m[14] as number]);
139
133
  }
140
134
  return { ok: true, value: undefined };
141
135
  },