@forgeax/engine-app 0.1.4 → 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 +331 -362
  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 +2 -3
  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
@@ -28,24 +28,21 @@
28
28
  // seam insertion point)
29
29
 
30
30
  import { World } from '@forgeax/engine-ecs';
31
- import type { Renderer } from '@forgeax/engine-render';
31
+ import { createRenderReadLease } from '@forgeax/engine-ecs/projection';
32
+ import type { Renderer, RenderFrameInput } from '@forgeax/engine-render';
32
33
  import { describe, expect, it } from 'vitest';
33
34
  import { createFrameLoop, type FrameLoopOptions } from '../internal/frame-loop';
34
35
 
35
36
  interface DrawCall {
36
- readonly worlds: unknown;
37
- readonly options: unknown;
37
+ readonly request: RenderFrameInput;
38
38
  }
39
39
 
40
40
  function makeSpyRenderer(): { renderer: Renderer; calls: DrawCall[] } {
41
41
  const calls: DrawCall[] = [];
42
42
  const renderer = {
43
- backend: 'webgpu' as const,
44
- ready: Promise.resolve({ ok: true, value: undefined }),
45
- attachWorld: () => ({ ok: true, value: undefined }),
46
- detachWorld: () => {},
47
- draw(worlds: unknown, options: unknown): { ok: true; value: undefined } {
48
- calls.push({ worlds, options });
43
+ attach: (world: World) => ({ ok: true, value: createRenderReadLease(world) }),
44
+ draw(request: RenderFrameInput): { ok: true; value: undefined } {
45
+ calls.push({ request });
49
46
  return { ok: true, value: undefined };
50
47
  },
51
48
  onError(): () => void {
@@ -119,11 +116,10 @@ describe('drawSource seam degrades to single-world path (w8, AC-03 regression)',
119
116
 
120
117
  expect(calls.length).toBeGreaterThanOrEqual(3);
121
118
  for (const call of calls) {
122
- expect(Array.isArray(call.worlds)).toBe(true);
123
- const arr = call.worlds as unknown[];
124
- expect(arr.length).toBe(1);
125
- expect(arr[0]).toBe(world);
126
- expect(call.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
119
+ expect(call.request.leases).toHaveLength(1);
120
+ expect(call.request.leases[0]?.worldIdentity).toBe(world.identity);
121
+ expect(call.request.camera.lease).toBe(call.request.leases[0]);
122
+ expect(call.request.environment.lease).toBe(call.request.leases[0]);
127
123
  }
128
124
 
129
125
  loop.stop();
@@ -150,11 +146,10 @@ describe('drawSource seam degrades to single-world path (w8, AC-03 regression)',
150
146
  expect(pulls).toBeGreaterThanOrEqual(3);
151
147
  expect(calls.length).toBeGreaterThanOrEqual(3);
152
148
  for (const call of calls) {
153
- expect(Array.isArray(call.worlds)).toBe(true);
154
- const arr = call.worlds as unknown[];
155
- expect(arr.length).toBe(1);
156
- expect(arr[0]).toBe(world);
157
- expect(call.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
149
+ expect(call.request.leases).toHaveLength(1);
150
+ expect(call.request.leases[0]?.worldIdentity).toBe(world.identity);
151
+ expect(call.request.camera.lease).toBe(call.request.leases[0]);
152
+ expect(call.request.environment.lease).toBe(call.request.leases[0]);
158
153
  }
159
154
 
160
155
  loop.stop();
@@ -175,11 +170,15 @@ describe('drawSource seam degrades to single-world path (w8, AC-03 regression)',
175
170
  pump(1);
176
171
 
177
172
  expect(calls).toHaveLength(3);
178
- expect(calls[0]?.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
179
- expect(calls[1]?.worlds).toEqual([world, overlay]);
180
- expect(calls[1]?.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
181
- expect(calls[2]?.worlds).toEqual([world]);
182
- expect(calls[2]?.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
173
+ expect(calls[0]?.request.leases).toHaveLength(1);
174
+ expect(calls[0]?.request.leases[0]?.worldIdentity).toBe(world.identity);
175
+ expect(calls[1]?.request.leases).toHaveLength(2);
176
+ expect(calls[1]?.request.leases[0]?.worldIdentity).toBe(world.identity);
177
+ expect(calls[1]?.request.leases[1]?.worldIdentity).toBe(overlay.identity);
178
+ expect(calls[1]?.request.camera.lease).toBe(calls[1]?.request.leases[0]);
179
+ expect(calls[1]?.request.environment.lease).toBe(calls[1]?.request.leases[0]);
180
+ expect(calls[2]?.request.leases).toHaveLength(1);
181
+ expect(calls[2]?.request.leases[0]?.worldIdentity).toBe(world.identity);
183
182
  loop.stop();
184
183
  });
185
184
  });
@@ -38,6 +38,10 @@ describe('Engine Worker thick bootstrap channel', () => {
38
38
  bootstrapUrl: 'https://example.test/bootstrap.js',
39
39
  bootstrapData: { gameId: 'sample' },
40
40
  bootstrapPort: channel.port2,
41
+ assetCatalog: {
42
+ url: '/__pack/scopes/sample/7/catalog.json',
43
+ expectedScope: { scopeId: 'sample', generation: 7 },
44
+ },
41
45
  timeoutMs: 100,
42
46
  tier: 'engine-worker',
43
47
  workerFactory: () => worker as unknown as Worker,
@@ -49,6 +53,10 @@ describe('Engine Worker thick bootstrap channel', () => {
49
53
  kind: 'init',
50
54
  bootstrapData: { gameId: 'sample' },
51
55
  bootstrapPort: channel.port2,
56
+ assetCatalog: {
57
+ url: '/__pack/scopes/sample/7/catalog.json',
58
+ expectedScope: { scopeId: 'sample', generation: 7 },
59
+ },
52
60
  });
53
61
  expect(worker.posts[0]?.transfer).toEqual([offscreen, channel.port2]);
54
62
  started.ok && started.value.dispose();
@@ -1,12 +1,19 @@
1
1
  import { World } from '@forgeax/engine-ecs';
2
- import type { Renderer } from '@forgeax/engine-render';
2
+ import type { Renderer, RenderWorldLease } from '@forgeax/engine-render';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import { commitAttachedWorld, SerializedRebuildQueue } from '../execution/attached-world-swap';
5
5
 
6
- function rendererOwner(): Pick<Renderer, 'attachWorld' | 'detachWorld'> {
6
+ function rendererOwner(): Pick<Renderer, 'attach'> {
7
+ const lease = {
8
+ worldIdentity: {},
9
+ generation: 0,
10
+ readChanges: vi.fn(),
11
+ querySpans: vi.fn(),
12
+ inspectCursor: vi.fn(),
13
+ dispose: vi.fn(),
14
+ } as unknown as RenderWorldLease;
7
15
  return {
8
- attachWorld: vi.fn(() => ({ ok: true as const, value: undefined })),
9
- detachWorld: vi.fn(),
16
+ attach: vi.fn(() => ({ ok: true as const, value: lease })),
10
17
  };
11
18
  }
12
19
 
@@ -36,41 +43,27 @@ describe('worker attached World swap', () => {
36
43
 
37
44
  it('publishes the candidate before detaching the previous World', async () => {
38
45
  const renderer = rendererOwner();
39
- const previous = new World();
40
46
  const next = new World();
41
47
 
42
- await expect(commitAttachedWorld(renderer, previous, next, async () => true)).resolves.toBe(
43
- true,
44
- );
45
- expect(renderer.attachWorld).toHaveBeenCalledWith(next);
46
- expect(renderer.detachWorld).toHaveBeenCalledOnce();
47
- expect(renderer.detachWorld).toHaveBeenCalledWith(previous);
48
+ await expect(commitAttachedWorld(renderer, next, async () => true)).resolves.toBe(true);
49
+ expect(renderer.attach).toHaveBeenCalledWith(next);
48
50
  });
49
51
 
50
52
  it('detaches a candidate whose bootstrap fails and keeps the previous World', async () => {
51
53
  const renderer = rendererOwner();
52
- const previous = new World();
53
54
  const next = new World();
54
55
 
55
- await expect(commitAttachedWorld(renderer, previous, next, async () => false)).resolves.toBe(
56
- false,
57
- );
58
- expect(renderer.detachWorld).toHaveBeenCalledOnce();
59
- expect(renderer.detachWorld).toHaveBeenCalledWith(next);
60
- expect(renderer.detachWorld).not.toHaveBeenCalledWith(previous);
56
+ await expect(commitAttachedWorld(renderer, next, async () => false)).resolves.toBe(false);
61
57
  });
62
58
 
63
59
  it('detaches a candidate whose bootstrap throws', async () => {
64
60
  const renderer = rendererOwner();
65
- const previous = new World();
66
61
  const next = new World();
67
62
 
68
63
  await expect(
69
- commitAttachedWorld(renderer, previous, next, async () => {
64
+ commitAttachedWorld(renderer, next, async () => {
70
65
  throw new Error('bootstrap failed');
71
66
  }),
72
67
  ).rejects.toThrow('bootstrap failed');
73
- expect(renderer.detachWorld).toHaveBeenCalledOnce();
74
- expect(renderer.detachWorld).toHaveBeenCalledWith(next);
75
68
  });
76
69
  });
@@ -34,7 +34,6 @@ describe('AppErrorCode closed union', () => {
34
34
  'app-not-started',
35
35
  'app-already-running',
36
36
  'app-canvas-detached',
37
- 'app-invalid-canvas-pixel-ratio',
38
37
  'app-frame-step-invalid',
39
38
  'app-system-update-failed',
40
39
  'app-pointer-lock-failed',
@@ -47,11 +46,11 @@ describe('AppErrorCode closed union', () => {
47
46
  'app-execution-rebuild-failed',
48
47
  ];
49
48
 
50
- expect(allCodes).toHaveLength(14);
49
+ expect(allCodes).toHaveLength(13);
51
50
 
52
51
  // Each member must be unique.
53
52
  const unique = new Set(allCodes);
54
- expect(unique.size).toBe(14);
53
+ expect(unique.size).toBe(13);
55
54
  });
56
55
 
57
56
  it('exhaustive switch over AppErrorCode has zero default branch', () => {
@@ -68,8 +67,6 @@ describe('AppErrorCode closed union', () => {
68
67
  return 'already running';
69
68
  case 'app-canvas-detached':
70
69
  return 'canvas detached';
71
- case 'app-invalid-canvas-pixel-ratio':
72
- return 'invalid canvas pixel ratio';
73
70
  case 'app-frame-step-invalid':
74
71
  return 'frame step invalid';
75
72
  case 'app-system-update-failed':
@@ -189,12 +186,12 @@ describe("'app-pointer-lock-failed' detail shape", () => {
189
186
  describe('APP_EXPECTED / APP_ERROR_HINTS bidirectional symmetry', () => {
190
187
  it('APP_EXPECTED has one key per AppErrorCode member', () => {
191
188
  const keys = Object.keys(APP_EXPECTED);
192
- expect(keys).toHaveLength(14);
189
+ expect(keys).toHaveLength(13);
193
190
  });
194
191
 
195
192
  it('APP_ERROR_HINTS has one key per AppErrorCode member', () => {
196
193
  const keys = Object.keys(APP_ERROR_HINTS);
197
- expect(keys).toHaveLength(14);
194
+ expect(keys).toHaveLength(13);
198
195
  });
199
196
 
200
197
  it('APP_EXPECTED and APP_ERROR_HINTS have the same key set', () => {
@@ -1,4 +1,4 @@
1
- import { createWorldContext, Update, World } from '@forgeax/engine-ecs';
1
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import {
4
4
  executionBootstrapHostPlugin,
@@ -44,9 +44,8 @@ describe('execution bootstrap isolation', () => {
44
44
  const url = moduleUrl(`export default function(){return {plugins:[{
45
45
  name:'module-system',inject:['world'],apply(ctx){
46
46
  const system={name:'module-system',queries:[],fn(){}};
47
- const update=ctx.world.scheduleToken('Update');
48
- ctx.world.addSystem(update,system).unwrap();
49
- ctx.effect(()=>()=>ctx.world.removeSystem(update,system.name),'test/system');
47
+ ctx.world.addSystem(ctx.world.scheduleToken('Update'),system).unwrap();
48
+ ctx.effect(()=>()=>ctx.world.removeSystem(ctx.world.scheduleToken('Update'),system.name),'test/system');
50
49
  }
51
50
  }]}}`);
52
51
  const prepared = await prepareBootstrapEntry(url, undefined);
@@ -54,9 +53,9 @@ describe('execution bootstrap isolation', () => {
54
53
  if (!prepared.ok) return;
55
54
  const world = new World();
56
55
  const context = await createWorldContext(world, prepared.value.plugins ?? []);
57
- expect(world.inspect().scheduleSystemCount(Update)).toBe(1);
56
+ expect(world.inspect().scheduleSystemCount(world.scheduleToken('Update'))).toBe(1);
58
57
  await context.fiber.dispose();
59
- expect(world.inspect().scheduleSystemCount(Update)).toBe(0);
58
+ expect(world.inspect().scheduleSystemCount(world.scheduleToken('Update'))).toBe(0);
60
59
  });
61
60
 
62
61
  it('rejects a missing default export structurally', async () => {
@@ -21,8 +21,8 @@ describe('execution frame ordering', () => {
21
21
  };
22
22
  };
23
23
  const ledger = new FrameCreditLedger('world');
24
- const frame = ledger.issue(1 / 60, sample, { width: 320, height: 180 });
25
- expect(ledger.issue(1 / 60, sample, { width: 320, height: 180 })).toBeUndefined();
24
+ const frame = ledger.issue(1 / 60, sample);
25
+ expect(ledger.issue(1 / 60, sample)).toBeUndefined();
26
26
  trace.push('update', 'draw');
27
27
  ledger.complete({
28
28
  kind: 'frame-complete',
@@ -16,10 +16,9 @@ const sample: InputBackendSample = {
16
16
  describe('frame credit protocol', () => {
17
17
  it('allows one in-flight frame and handles duplicate, late and old identities without writes', () => {
18
18
  const ledger = new FrameCreditLedger('world-1');
19
- const first = ledger.issue(0.016, () => sample, { width: 320, height: 180 });
19
+ const first = ledger.issue(0.016, () => sample);
20
20
  expect(first?.frameId).toBe(1);
21
- expect(first).toMatchObject({ canvasWidth: 320, canvasHeight: 180 });
22
- expect(ledger.issue(0.016, () => sample, { width: 320, height: 180 })).toBeUndefined();
21
+ expect(ledger.issue(0.016, () => sample)).toBeUndefined();
23
22
  expect(
24
23
  ledger.complete({
25
24
  kind: 'frame-complete',
@@ -56,6 +55,6 @@ describe('frame credit protocol', () => {
56
55
  kernelWaitMs: 0,
57
56
  }),
58
57
  ).toBe('duplicate');
59
- expect(ledger.issue(0.016, () => sample, { width: 640, height: 320 })?.frameId).toBe(2);
58
+ expect(ledger.issue(0.016, () => sample)?.frameId).toBe(2);
60
59
  });
61
60
  });
@@ -7,4 +7,8 @@ it('discovers execution from the public App surface', () => {
7
7
  expectTypeOf(app.execution).toEqualTypeOf<ExecutionControl>();
8
8
  expectTypeOf(app.execution.report()).toEqualTypeOf<ExecutionReport>();
9
9
  expectTypeOf(options.execution?.bootstrap).toEqualTypeOf<string | URL | undefined>();
10
+ expectTypeOf(options.execution?.assetCatalog?.url).toEqualTypeOf<string | undefined>();
11
+ expectTypeOf(options.execution?.assetCatalog?.expectedScope).toEqualTypeOf<
12
+ { readonly scopeId: string; readonly generation: number } | undefined
13
+ >();
10
14
  });
@@ -20,7 +20,7 @@ function makeSpyRenderer(): Renderer {
20
20
  return {
21
21
  backend: 'webgpu' as const,
22
22
  ready: Promise.resolve({ ok: true, value: undefined }),
23
- attachWorld: () => ({ ok: true, value: undefined }),
23
+ attach: () => ({ ok: true, value: undefined }),
24
24
  detachWorld: () => {},
25
25
  draw(): { ok: true; value: undefined } {
26
26
  return { ok: true, value: undefined };
@@ -8,7 +8,7 @@ function renderer(): Renderer {
8
8
  return {
9
9
  backend: 'webgpu',
10
10
  ready: Promise.resolve({ ok: true, value: undefined }),
11
- attachWorld: () => ({ ok: true, value: undefined }),
11
+ attach: () => ({ ok: true, value: undefined }),
12
12
  detachWorld: () => {},
13
13
  draw: () => ({ ok: true, value: undefined }),
14
14
  onError: () => () => {},
@@ -15,27 +15,38 @@
15
15
  // world identity path (worldId 0) is preserved (AC-03 regression guarantee).
16
16
 
17
17
  import { Update, World } from '@forgeax/engine-ecs';
18
- import type { Renderer } from '@forgeax/engine-render';
18
+ import type { Renderer, RenderWorldLease } from '@forgeax/engine-render';
19
19
  import { describe, expect, it, vi } from 'vitest';
20
20
  import { createFrameLoop } from '../internal/frame-loop';
21
21
 
22
22
  interface DrawCall {
23
- readonly worlds: unknown;
24
- readonly options: unknown;
23
+ readonly request: unknown;
24
+ }
25
+
26
+ function lease(onDispose: () => void = () => {}): RenderWorldLease {
27
+ return {
28
+ worldIdentity: {},
29
+ generation: 0,
30
+ readChanges: vi.fn(),
31
+ querySpans: vi.fn(),
32
+ inspectCursor: vi.fn(),
33
+ dispose: onDispose,
34
+ } as unknown as RenderWorldLease;
25
35
  }
26
36
 
27
37
  function makeSpyRenderer(): { renderer: Renderer; calls: DrawCall[] } {
28
38
  const calls: DrawCall[] = [];
29
39
  const renderer = {
30
40
  backend: 'webgpu' as const,
31
- ready: Promise.resolve({ ok: true, value: undefined }),
32
- attachWorld(): { ok: true; value: undefined } {
33
- return { ok: true, value: undefined };
41
+ attach(): { ok: true; value: RenderWorldLease } {
42
+ return { ok: true, value: lease() };
34
43
  },
35
- detachWorld(): void {},
36
- draw(worlds: unknown, options: unknown): { ok: true; value: undefined } {
37
- calls.push({ worlds, options });
38
- return { ok: true, value: undefined };
44
+ draw(request: unknown): {
45
+ ok: true;
46
+ value: { frameId: number; deviceGeneration: number; completed: true };
47
+ } {
48
+ calls.push({ request });
49
+ return { ok: true, value: { frameId: calls.length, deviceGeneration: 0, completed: true } };
39
50
  },
40
51
  onError(): () => void {
41
52
  return () => {
@@ -92,15 +103,13 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
92
103
  .unwrap();
93
104
  const renderer = {
94
105
  backend: 'webgpu' as const,
95
- ready: Promise.resolve({ ok: true, value: undefined }),
96
- attachWorld(): { ok: true; value: undefined } {
106
+ attach(): { ok: true; value: RenderWorldLease } {
97
107
  events.push('attach');
98
- return { ok: true, value: undefined };
108
+ return { ok: true, value: lease() };
99
109
  },
100
- detachWorld(): void {},
101
- draw(): { ok: true; value: undefined } {
110
+ draw(): { ok: true; value: { frameId: number; deviceGeneration: number; completed: true } } {
102
111
  events.push('draw');
103
- return { ok: true, value: undefined };
112
+ return { ok: true, value: { frameId: 1, deviceGeneration: 0, completed: true } };
104
113
  },
105
114
  onError: () => () => {},
106
115
  dispose: () => {},
@@ -133,7 +142,7 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
133
142
  ]);
134
143
  });
135
144
 
136
- it('calls renderer.draw([world], { cameraOwner: 0, resourceOwner: 0 }) once per running frame', () => {
145
+ it('calls renderer.draw with one lease-bound world per running frame', () => {
137
146
  const world = new World();
138
147
  const { renderer, calls } = makeSpyRenderer();
139
148
  const { raf, caf, now, pump } = makeSyncScheduler();
@@ -148,13 +157,11 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
148
157
 
149
158
  expect(calls.length).toBeGreaterThanOrEqual(3);
150
159
  for (const call of calls) {
151
- // The worlds argument is an array carrying exactly the single world.
152
- expect(Array.isArray(call.worlds)).toBe(true);
153
- const arr = call.worlds as unknown[];
154
- expect(arr.length).toBe(1);
155
- expect(arr[0]).toBe(world);
156
- // owner is the required, defaulted-to-0 index (single-world identity).
157
- expect(call.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
160
+ expect(call.request).toMatchObject({
161
+ leases: [{ worldIdentity: expect.any(Object) }],
162
+ camera: { lease: expect.any(Object) },
163
+ environment: { lease: expect.any(Object) },
164
+ });
158
165
  }
159
166
 
160
167
  loop.stop();
@@ -202,11 +209,9 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
202
209
  const rhiErr = { code: 'rhi-not-available' } as const;
203
210
  const renderer = {
204
211
  backend: 'webgpu' as const,
205
- ready: Promise.resolve({ ok: true, value: undefined }),
206
- attachWorld: () => ({ ok: true, value: undefined }),
207
- detachWorld: () => {},
208
- draw(worlds: unknown, options: unknown): { ok: false; error: typeof rhiErr } {
209
- calls.push({ worlds, options });
212
+ attach: () => ({ ok: true as const, value: lease() }),
213
+ draw(request: unknown): { ok: false; error: typeof rhiErr } {
214
+ calls.push({ request });
210
215
  return { ok: false, error: rhiErr };
211
216
  },
212
217
  onError(): () => void {
@@ -226,26 +231,24 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
226
231
  // start() schedules the first tick via raf; pump once to run it.
227
232
  pump(1);
228
233
 
229
- expect(calls[0]?.worlds).toEqual([world]);
230
- expect(calls[0]?.options).toEqual({ cameraOwner: 0, resourceOwner: 0 });
234
+ expect(calls[0]?.request).toMatchObject({
235
+ camera: { lease: expect.any(Object) },
236
+ environment: { lease: expect.any(Object) },
237
+ });
231
238
  expect(onError).toHaveBeenCalledWith(rhiErr);
232
239
 
233
240
  loop.stop();
234
241
  });
235
242
 
236
- it('keeps the rAF heartbeat but freezes draw work during device loss', () => {
243
+ it('keeps the rAF heartbeat and submits receipt-bound frames', () => {
237
244
  const world = new World();
238
- let reason: 'alive' | 'device-lost' = 'alive';
239
245
  const calls: unknown[] = [];
240
246
  const renderer = {
241
247
  backend: 'webgpu' as const,
242
- ready: Promise.resolve({ ok: true, value: undefined }),
243
- attachWorld: () => ({ ok: true, value: undefined }),
244
- detachWorld: () => {},
245
- health: () => ({ reason, recoverable: reason === 'device-lost' }),
246
- draw(): { ok: true; value: undefined } {
248
+ attach: () => ({ ok: true as const, value: lease() }),
249
+ draw(): { ok: true; value: { frameId: number; deviceGeneration: number; completed: true } } {
247
250
  calls.push(undefined);
248
- return { ok: true, value: undefined };
251
+ return { ok: true, value: { frameId: calls.length, deviceGeneration: 0, completed: true } };
249
252
  },
250
253
  onError(): () => void {
251
254
  return () => {};
@@ -257,14 +260,8 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
257
260
  loop.start();
258
261
  pump(1);
259
262
  expect(calls).toHaveLength(1);
260
-
261
- reason = 'device-lost';
262
263
  pump(3);
263
- expect(calls).toHaveLength(1);
264
-
265
- reason = 'alive';
266
- pump(1);
267
- expect(calls).toHaveLength(2);
264
+ expect(calls).toHaveLength(4);
268
265
  loop.stop();
269
266
  });
270
267
 
@@ -282,7 +279,12 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
282
279
  expect(stepped.ok).toBe(true);
283
280
  expect(update).toHaveBeenCalledTimes(1);
284
281
  expect(update).toHaveBeenCalledWith(1 / 60);
285
- expect(calls).toEqual([{ worlds: [world], options: { cameraOwner: 0, resourceOwner: 0 } }]);
282
+ expect(calls).toHaveLength(1);
283
+ expect(calls[0]?.request).toMatchObject({
284
+ leases: [{ worldIdentity: expect.any(Object) }],
285
+ camera: { lease: expect.any(Object) },
286
+ environment: { lease: expect.any(Object) },
287
+ });
286
288
 
287
289
  expect(loop.resume().ok).toBe(true);
288
290
  expect(loop.stop().ok).toBe(true);
@@ -298,7 +300,9 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
298
300
  expect(idle.ok).toBe(false);
299
301
  if (!idle.ok) {
300
302
  expect(idle.error.code).toBe('app-frame-step-invalid');
301
- expect(idle.error.detail).toEqual({ state: 'idle', deltaSeconds: 1 / 60, reason: 'state' });
303
+ if ('detail' in idle.error) {
304
+ expect(idle.error.detail).toEqual({ state: 'idle', deltaSeconds: 1 / 60, reason: 'state' });
305
+ }
302
306
  }
303
307
 
304
308
  expect(loop.start().ok).toBe(true);
@@ -322,15 +326,14 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
322
326
  const detached: World[] = [];
323
327
  const renderer = {
324
328
  backend: 'webgpu' as const,
325
- ready: Promise.resolve({ ok: true, value: undefined }),
326
- attachWorld(candidate: World) {
329
+ attach(candidate: World) {
327
330
  attached.push(candidate);
328
- return { ok: true as const, value: undefined };
329
- },
330
- detachWorld(candidate: World): void {
331
- detached.push(candidate);
331
+ return { ok: true as const, value: lease(() => detached.push(candidate)) };
332
332
  },
333
- draw: () => ({ ok: true as const, value: undefined }),
333
+ draw: () => ({
334
+ ok: true as const,
335
+ value: { frameId: 1, deviceGeneration: 0, completed: true as const },
336
+ }),
334
337
  onError: () => () => {},
335
338
  dispose: () => {},
336
339
  } as unknown as Renderer;
@@ -361,12 +364,14 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
361
364
  const detached: World[] = [];
362
365
  const renderer = {
363
366
  backend: 'webgpu' as const,
364
- ready: Promise.resolve({ ok: true, value: undefined }),
365
- attachWorld: () => ({ ok: true as const, value: undefined }),
366
- detachWorld(candidate: World): void {
367
- detached.push(candidate);
368
- },
369
- draw: () => ({ ok: true as const, value: undefined }),
367
+ attach: (candidate: World) => ({
368
+ ok: true as const,
369
+ value: lease(() => detached.push(candidate)),
370
+ }),
371
+ draw: () => ({
372
+ ok: true as const,
373
+ value: { frameId: 1, deviceGeneration: 0, completed: true as const },
374
+ }),
370
375
  onError: () => () => {},
371
376
  dispose: () => {},
372
377
  } as unknown as Renderer;
@@ -392,12 +397,14 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
392
397
  const detached: World[] = [];
393
398
  const renderer = {
394
399
  backend: 'webgpu' as const,
395
- ready: Promise.resolve({ ok: true, value: undefined }),
396
- attachWorld: () => ({ ok: true as const, value: undefined }),
397
- detachWorld(candidate: World): void {
398
- detached.push(candidate);
399
- },
400
- draw: () => ({ ok: true as const, value: undefined }),
400
+ attach: (candidate: World) => ({
401
+ ok: true as const,
402
+ value: lease(() => detached.push(candidate)),
403
+ }),
404
+ draw: () => ({
405
+ ok: true as const,
406
+ value: { frameId: 1, deviceGeneration: 0, completed: true as const },
407
+ }),
401
408
  onError: () => () => {},
402
409
  dispose: () => {},
403
410
  } as unknown as Renderer;
@@ -418,12 +425,14 @@ describe('M3 / m3-t3 — frame-loop wraps the single World into [world] with own
418
425
  const detached: World[] = [];
419
426
  const renderer = {
420
427
  backend: 'webgpu' as const,
421
- ready: Promise.resolve({ ok: true, value: undefined }),
422
- attachWorld: () => ({ ok: true as const, value: undefined }),
423
- detachWorld(candidate: World): void {
424
- detached.push(candidate);
425
- },
426
- draw: () => ({ ok: true as const, value: undefined }),
428
+ attach: (candidate: World) => ({
429
+ ok: true as const,
430
+ value: lease(() => detached.push(candidate)),
431
+ }),
432
+ draw: () => ({
433
+ ok: true as const,
434
+ value: { frameId: 1, deviceGeneration: 0, completed: true as const },
435
+ }),
427
436
  onError: () => () => {},
428
437
  dispose: () => {},
429
438
  } as unknown as Renderer;
@@ -2,13 +2,13 @@ import type { AudioBackend } from '@forgeax/engine-audio';
2
2
  import { createWorldContext, World } from '@forgeax/engine-ecs';
3
3
  import { type InputBackend, ownedInputBackendPlugin } from '@forgeax/engine-input';
4
4
  import { Context, type Plugin } from '@forgeax/engine-plugin';
5
- import { ownedRendererPlugin, type Renderer } from '@forgeax/engine-render';
5
+ import type { Renderer } from '@forgeax/engine-render';
6
6
  import { describe, expect, it } from 'vitest';
7
-
8
7
  import { assembledEngineProfile } from '../internal/assembled-engine-profile';
9
8
  import { mainEngineProfile } from '../internal/main-engine-profile';
10
9
  import { remoteServerPlugin } from '../internal/remote-server-plugin';
11
10
  import { workerEngineProfile } from '../internal/worker-engine-profile';
11
+ import { ownedRendererPlugin } from '../renderer-plugin';
12
12
 
13
13
  interface PluginMetadata {
14
14
  readonly name?: string;
@@ -74,7 +74,7 @@ function expectDag(profile: readonly Plugin[], roots: readonly string[]): void {
74
74
  expect(visited, 'built-in profile dependency cycle').toBe(profile.length);
75
75
  }
76
76
 
77
- const renderer = { assets: {} } as Renderer;
77
+ const renderer = { assets: {} } as unknown as Renderer;
78
78
  const input = { sample: () => ({}) } as InputBackend;
79
79
  const audio = {} as AudioBackend;
80
80
  describe('Engine built-in plugin profiles', () => {
@@ -9,7 +9,7 @@ function makeRenderer(): Renderer {
9
9
  return {
10
10
  backend: 'webgpu',
11
11
  ready: Promise.resolve({ ok: true, value: undefined }),
12
- attachWorld: () => ({ ok: true, value: undefined }),
12
+ attach: () => ({ ok: true, value: undefined }),
13
13
  detachWorld: () => {},
14
14
  draw: () => ({ ok: true, value: undefined }),
15
15
  onError: () => () => {},