@forgeax/engine-input 0.0.0-dev.8d955ade1c79

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 (107) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +312 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/browser-backend-dual-instance.test.d.ts +2 -0
  5. package/dist/__tests__/browser-backend-dual-instance.test.d.ts.map +1 -0
  6. package/dist/__tests__/browser-backend-focus-loss.test.d.ts +2 -0
  7. package/dist/__tests__/browser-backend-focus-loss.test.d.ts.map +1 -0
  8. package/dist/__tests__/browser-backend-lock-error.test.d.ts +2 -0
  9. package/dist/__tests__/browser-backend-lock-error.test.d.ts.map +1 -0
  10. package/dist/__tests__/browser-backend-lock-gate.test.d.ts +2 -0
  11. package/dist/__tests__/browser-backend-lock-gate.test.d.ts.map +1 -0
  12. package/dist/__tests__/browser-backend-lock-release.test.d.ts +2 -0
  13. package/dist/__tests__/browser-backend-lock-release.test.d.ts.map +1 -0
  14. package/dist/__tests__/browser-backend-lock-state.test.d.ts +2 -0
  15. package/dist/__tests__/browser-backend-lock-state.test.d.ts.map +1 -0
  16. package/dist/__tests__/canvas-input-boundary.test.d.ts +2 -0
  17. package/dist/__tests__/canvas-input-boundary.test.d.ts.map +1 -0
  18. package/dist/__tests__/canvas-input-owner.test-d.d.ts +2 -0
  19. package/dist/__tests__/canvas-input-owner.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/composite-backend.test.d.ts +2 -0
  21. package/dist/__tests__/composite-backend.test.d.ts.map +1 -0
  22. package/dist/__tests__/controller-platform-owner.test-d.d.ts +2 -0
  23. package/dist/__tests__/controller-platform-owner.test-d.d.ts.map +1 -0
  24. package/dist/__tests__/input-snapshot.test-d.d.ts +2 -0
  25. package/dist/__tests__/input-snapshot.test-d.d.ts.map +1 -0
  26. package/dist/__tests__/input-snapshot.test.d.ts +2 -0
  27. package/dist/__tests__/input-snapshot.test.d.ts.map +1 -0
  28. package/dist/__tests__/swipe-direction-owner.test-d.d.ts +2 -0
  29. package/dist/__tests__/swipe-direction-owner.test-d.d.ts.map +1 -0
  30. package/dist/__tests__/ui-input-matrix.test.d.ts +2 -0
  31. package/dist/__tests__/ui-input-matrix.test.d.ts.map +1 -0
  32. package/dist/__tests__/ui-ownership-edge.test.d.ts +2 -0
  33. package/dist/__tests__/ui-ownership-edge.test.d.ts.map +1 -0
  34. package/dist/__tests__/ui-ownership.test.d.ts +2 -0
  35. package/dist/__tests__/ui-ownership.test.d.ts.map +1 -0
  36. package/dist/__tests__/ui-reset-idempotence.test.d.ts +2 -0
  37. package/dist/__tests__/ui-reset-idempotence.test.d.ts.map +1 -0
  38. package/dist/__tests__/ui-reset.test.d.ts +2 -0
  39. package/dist/__tests__/ui-reset.test.d.ts.map +1 -0
  40. package/dist/action-state.d.ts +146 -0
  41. package/dist/action-state.d.ts.map +1 -0
  42. package/dist/browser-backend.d.ts +121 -0
  43. package/dist/browser-backend.d.ts.map +1 -0
  44. package/dist/canvas-input-boundary.d.ts +13 -0
  45. package/dist/canvas-input-boundary.d.ts.map +1 -0
  46. package/dist/composite-backend.d.ts +34 -0
  47. package/dist/composite-backend.d.ts.map +1 -0
  48. package/dist/controller-db-data.d.ts +5 -0
  49. package/dist/controller-db-data.d.ts.map +1 -0
  50. package/dist/controller-db-data.mjs +12 -0
  51. package/dist/controller-db-data.mjs.map +1 -0
  52. package/dist/controller-db.d.ts +70 -0
  53. package/dist/controller-db.d.ts.map +1 -0
  54. package/dist/controller-db.mjs +119 -0
  55. package/dist/controller-db.mjs.map +1 -0
  56. package/dist/frame-start-scan-system.d.ts +37 -0
  57. package/dist/frame-start-scan-system.d.ts.map +1 -0
  58. package/dist/gamepad-frame.d.ts +42 -0
  59. package/dist/gamepad-frame.d.ts.map +1 -0
  60. package/dist/gesture-recognizer.d.ts +145 -0
  61. package/dist/gesture-recognizer.d.ts.map +1 -0
  62. package/dist/index.d.ts +13 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.mjs +1502 -0
  65. package/dist/index.mjs.map +1 -0
  66. package/dist/input-snapshot.d.ts +420 -0
  67. package/dist/input-snapshot.d.ts.map +1 -0
  68. package/dist/plugin-service.d.ts +11 -0
  69. package/dist/plugin-service.d.ts.map +1 -0
  70. package/dist/ui-ownership.d.ts +17 -0
  71. package/dist/ui-ownership.d.ts.map +1 -0
  72. package/dist/virtual-joystick.d.ts +39 -0
  73. package/dist/virtual-joystick.d.ts.map +1 -0
  74. package/package.json +72 -0
  75. package/src/__tests__/browser-backend-dual-instance.test.ts +379 -0
  76. package/src/__tests__/browser-backend-focus-loss.test.ts +239 -0
  77. package/src/__tests__/browser-backend-lock-error.test.ts +425 -0
  78. package/src/__tests__/browser-backend-lock-gate.test.ts +283 -0
  79. package/src/__tests__/browser-backend-lock-release.test.ts +641 -0
  80. package/src/__tests__/browser-backend-lock-state.test.ts +604 -0
  81. package/src/__tests__/canvas-input-boundary.test.ts +65 -0
  82. package/src/__tests__/canvas-input-owner.test-d.ts +39 -0
  83. package/src/__tests__/composite-backend.test.ts +299 -0
  84. package/src/__tests__/controller-platform-owner.test-d.ts +46 -0
  85. package/src/__tests__/input-snapshot.test-d.ts +78 -0
  86. package/src/__tests__/input-snapshot.test.ts +96 -0
  87. package/src/__tests__/swipe-direction-owner.test-d.ts +45 -0
  88. package/src/__tests__/ui-input-matrix.test.ts +31 -0
  89. package/src/__tests__/ui-ownership-edge.test.ts +33 -0
  90. package/src/__tests__/ui-ownership.test.ts +35 -0
  91. package/src/__tests__/ui-reset-idempotence.test.ts +17 -0
  92. package/src/__tests__/ui-reset.test.ts +25 -0
  93. package/src/action-state.ts +380 -0
  94. package/src/browser-backend.ts +884 -0
  95. package/src/canvas-input-boundary.ts +81 -0
  96. package/src/composite-backend.ts +189 -0
  97. package/src/controller-db-data.ts +25 -0
  98. package/src/controller-db.ts +218 -0
  99. package/src/frame-start-scan-system.ts +93 -0
  100. package/src/gamepad-frame.ts +259 -0
  101. package/src/gesture-recognizer.ts +495 -0
  102. package/src/index.ts +101 -0
  103. package/src/input-snapshot.ts +732 -0
  104. package/src/plugin-service.ts +31 -0
  105. package/src/ui-ownership.ts +61 -0
  106. package/src/virtual-joystick.ts +103 -0
  107. package/vendor/gamecontrollerdb.txt +2114 -0
@@ -0,0 +1,604 @@
1
+ // browser-backend-lock-state.test.ts -- M1 w1: lock state machine unit tests.
2
+ //
3
+ // Covers: pointerlockchange → w3cLocked, providerLocked set/reset,
4
+ // merged pointerLocked = w3cLocked || providerLocked,
5
+ // snap.mouse.pointerLocked freeze in sample() output.
6
+ //
7
+ // charter awareness:
8
+ // F1 single-entry indexability -- all lock state tests in one file
9
+ // P3 explicit failure -- each assertion produces a clear diagnostic on failure
10
+ // P4 consistent abstraction -- tests use fake document/canvas, not real browser PointerLock
11
+
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { attachBrowserInputBackend } from '../browser-backend';
14
+ import { type InputBackendSample, snapshotFromSample } from '../input-snapshot';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Fake canvas / document / window infrastructure (mirrors buildBBFakes pattern
18
+ // from input.unit.test.ts, extended with pointerlockchange event support).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface LockStateFakeStore {
22
+ fire(target: string, kind: string, ev: Partial<Event>): void;
23
+ count(): number;
24
+ countTarget(target: string): number;
25
+ }
26
+
27
+ interface LockStateFakes {
28
+ canvas: HTMLCanvasElement;
29
+ doc: Document;
30
+ win: Window;
31
+ store: LockStateFakeStore;
32
+ setPointerLockElement(el: Element | null): void;
33
+ requestPointerLockReject: Error | null;
34
+ }
35
+
36
+ function buildLockStateFakes(): LockStateFakes {
37
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
38
+
39
+ const makeTarget = (label: string) => ({
40
+ addEventListener(kind: string, handler: EventListener): void {
41
+ let perTarget = listeners.get(label);
42
+ if (!perTarget) {
43
+ perTarget = new Map();
44
+ listeners.set(label, perTarget);
45
+ }
46
+ let set = perTarget.get(kind);
47
+ if (!set) {
48
+ set = new Set();
49
+ perTarget.set(kind, set);
50
+ }
51
+ set.add(handler);
52
+ },
53
+ removeEventListener(kind: string, handler: EventListener): void {
54
+ listeners.get(label)?.get(kind)?.delete(handler);
55
+ },
56
+ });
57
+
58
+ let pointerLockEl: Element | null = null;
59
+ const requestPointerLockReject: Error | null = null;
60
+
61
+ const canvas = {
62
+ ...makeTarget('canvas'),
63
+ requestPointerLock(): void {
64
+ // Marker: canvas requestPointerLock was called. The actual W3C
65
+ // resolution (resolve/reject) is driven by pointerlockchange event
66
+ // dispatch in tests, matching real-browser behavior.
67
+ },
68
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
69
+ width: 800,
70
+ height: 600,
71
+ style: { touchAction: '' },
72
+ setPointerCapture: vi.fn(),
73
+ } as unknown as HTMLCanvasElement;
74
+
75
+ const doc = {
76
+ ...makeTarget('document'),
77
+ hasFocus(): boolean {
78
+ return true;
79
+ },
80
+ visibilityState: 'visible',
81
+ get pointerLockElement(): Element | null {
82
+ return pointerLockEl;
83
+ },
84
+ exitPointerLock(): void {
85
+ // Simulate browser exiting pointer lock — sets pointerLockElement
86
+ // to null and fires pointerlockchange.
87
+ pointerLockEl = null;
88
+ // Fire pointerlockchange event on document
89
+ const handlers = listeners.get('document')?.get('pointerlockchange');
90
+ if (handlers) {
91
+ const ev = new Event('pointerlockchange');
92
+ for (const h of handlers) {
93
+ h(ev as Event);
94
+ }
95
+ }
96
+ },
97
+ } as unknown as Document;
98
+
99
+ const win = makeTarget('window') as unknown as Window;
100
+
101
+ const store: LockStateFakeStore = {
102
+ fire(target, kind, ev) {
103
+ const handlers = listeners.get(target)?.get(kind);
104
+ if (!handlers) return;
105
+ for (const h of handlers) {
106
+ h(ev as Event);
107
+ }
108
+ },
109
+ count() {
110
+ let total = 0;
111
+ for (const perTarget of listeners.values()) {
112
+ for (const set of perTarget.values()) {
113
+ total += set.size;
114
+ }
115
+ }
116
+ return total;
117
+ },
118
+ countTarget(target: string) {
119
+ const perTarget = listeners.get(target);
120
+ if (!perTarget) return 0;
121
+ let total = 0;
122
+ for (const set of perTarget.values()) total += set.size;
123
+ return total;
124
+ },
125
+ };
126
+
127
+ return {
128
+ canvas,
129
+ doc,
130
+ win,
131
+ store,
132
+ setPointerLockElement(el) {
133
+ pointerLockEl = el;
134
+ },
135
+ get requestPointerLockReject() {
136
+ return requestPointerLockReject;
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Simulate the browser firing a pointerlockchange event and setting the
143
+ * pointerLockElement. Both happen atomically in the real browser.
144
+ */
145
+ function firePointerLockChange(fakes: LockStateFakes, newLockedElement: Element | null): void {
146
+ fakes.setPointerLockElement(newLockedElement);
147
+ fakes.store.fire('document', 'pointerlockchange', new Event('pointerlockchange'));
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Tests
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('browser-backend-lock-state.test.ts (w1)', () => {
155
+ it('preserves physical KeyboardEvent.code held and release edges separately from key', () => {
156
+ const fakes = buildLockStateFakes();
157
+ const handle = attachBrowserInputBackend(fakes.canvas, {
158
+ document: fakes.doc,
159
+ window: fakes.win,
160
+ });
161
+
162
+ fakes.store.fire('window', 'keydown', { key: '?', code: 'KeyA' } as KeyboardEvent);
163
+ const pressed = handle.backend.sample();
164
+ expect(pressed.downKeys.has('?')).toBe(true);
165
+ expect(pressed.downCodes?.has('KeyA')).toBe(true);
166
+
167
+ fakes.store.fire('window', 'keyup', { key: '?', code: 'KeyA' } as KeyboardEvent);
168
+ const released = handle.backend.sample();
169
+ expect(released.downKeys.has('?')).toBe(false);
170
+ expect(released.downCodes?.has('KeyA')).toBe(false);
171
+ expect(released.upKeys.has('?')).toBe(true);
172
+ expect(released.upCodes?.has('KeyA')).toBe(true);
173
+
174
+ const next = handle.backend.sample();
175
+ expect(next.upKeys.has('?')).toBe(false);
176
+ expect(next.upCodes?.has('KeyA')).toBe(false);
177
+ handle.backend.detach?.();
178
+ });
179
+
180
+ it('tracks hover mouse position without a button press', () => {
181
+ const fakes = buildLockStateFakes();
182
+ const handle = attachBrowserInputBackend(fakes.canvas, {
183
+ document: fakes.doc,
184
+ window: fakes.win,
185
+ });
186
+
187
+ fakes.store.fire('canvas', 'pointermove', {
188
+ pointerType: 'mouse',
189
+ pointerId: 1,
190
+ pressure: 0,
191
+ clientX: 400,
192
+ clientY: 300,
193
+ movementX: 0,
194
+ movementY: 0,
195
+ } as unknown as Event);
196
+
197
+ const sample = handle.backend.sample();
198
+ expect(sample.mouseX).toBe(400);
199
+ expect(sample.mouseY).toBe(300);
200
+ expect(snapshotFromSample(sample).mouse.position).toEqual({ x: 400, y: 300 });
201
+ });
202
+
203
+ describe('W3C pointerlockchange → w3cLocked tracking', () => {
204
+ it('pointerlockchange with pointerLockElement === canvas sets w3cLocked=true in sample', () => {
205
+ const fakes = buildLockStateFakes();
206
+ const handle = attachBrowserInputBackend(fakes.canvas, {
207
+ document: fakes.doc,
208
+ window: fakes.win,
209
+ });
210
+ const backend = handle.backend;
211
+
212
+ // Before any pointerlockchange, pointerLocked should be false.
213
+ const s0 = backend.sample();
214
+ expect(s0.pointerLocked).toBe(false);
215
+
216
+ // Browser locks onto this canvas.
217
+ firePointerLockChange(fakes, fakes.canvas);
218
+ const s1 = backend.sample();
219
+ expect(s1.pointerLocked).toBe(true);
220
+
221
+ // Browser unlocks (pointerLockElement becomes null).
222
+ firePointerLockChange(fakes, null);
223
+ const s2 = backend.sample();
224
+ expect(s2.pointerLocked).toBe(false);
225
+ });
226
+
227
+ it('pointerlockchange with pointerLockElement !== canvas does NOT set w3cLocked', () => {
228
+ const fakes = buildLockStateFakes();
229
+ const handle = attachBrowserInputBackend(fakes.canvas, {
230
+ document: fakes.doc,
231
+ window: fakes.win,
232
+ });
233
+ const backend = handle.backend;
234
+
235
+ // Another element (e.g. a different canvas) gets the lock.
236
+ const otherCanvas = {} as HTMLCanvasElement;
237
+ firePointerLockChange(fakes, otherCanvas as unknown as Element);
238
+
239
+ const s = backend.sample();
240
+ expect(s.pointerLocked).toBe(false);
241
+ });
242
+
243
+ it('pointerlockchange listener is registered on document, not canvas', () => {
244
+ const fakes = buildLockStateFakes();
245
+ attachBrowserInputBackend(fakes.canvas, {
246
+ document: fakes.doc,
247
+ window: fakes.win,
248
+ });
249
+
250
+ // The pointerlockchange listener must be on the document (W3C spec:
251
+ // pointerlockchange fires on document, not on individual elements).
252
+ const docPointerLockChangeCount = fakes.store.countTarget('document');
253
+ expect(docPointerLockChangeCount).toBeGreaterThanOrEqual(1);
254
+
255
+ // The listener count must include the pointerlockchange handler.
256
+ // (We can't test the specific event without firing it, which we do
257
+ // in the pointerLocked tests above.)
258
+ });
259
+ });
260
+
261
+ describe('lockProvider → providerLocked tracking', () => {
262
+ it('lockProvider.requestLock() sync success sets providerLocked=true via sample', () => {
263
+ const fakes = buildLockStateFakes();
264
+ let providerLockedAtRequest = false;
265
+ const handle = attachBrowserInputBackend(fakes.canvas, {
266
+ document: fakes.doc,
267
+ window: fakes.win,
268
+ lockProvider: {
269
+ requestLock(): void {
270
+ providerLockedAtRequest = true;
271
+ },
272
+ exitLock(): void {
273
+ // no-op
274
+ },
275
+ },
276
+ });
277
+ const backend = handle.backend;
278
+
279
+ // Simulate a click that triggers lock request through the provider path.
280
+ // The onCanvasClick handler checks gate, finds lockProvider, calls requestLock.
281
+ // After requestLock() succeeds synchronously, providerLocked should be true.
282
+ fakes.store.fire('canvas', 'click', new Event('click'));
283
+
284
+ const s = backend.sample();
285
+ expect(s.pointerLocked).toBe(true);
286
+ // Verify that requestLock was indeed called (provider path was taken).
287
+ expect(providerLockedAtRequest).toBe(true);
288
+ });
289
+
290
+ it('lockProvider.exitLock() clears providerLocked via sample', () => {
291
+ const fakes = buildLockStateFakes();
292
+ let exitCalled = false;
293
+ const handle = attachBrowserInputBackend(fakes.canvas, {
294
+ document: fakes.doc,
295
+ window: fakes.win,
296
+ lockProvider: {
297
+ requestLock(): void {
298
+ // no-op
299
+ },
300
+ exitLock(): void {
301
+ exitCalled = true;
302
+ },
303
+ },
304
+ });
305
+ const backend = handle.backend;
306
+
307
+ // First, get into locked state via provider path.
308
+ fakes.store.fire('canvas', 'click', new Event('click'));
309
+ const s1 = backend.sample();
310
+ expect(s1.pointerLocked).toBe(true);
311
+
312
+ // Now simulate ESC keydown to trigger provider exit.
313
+ fakes.store.fire('window', 'keydown', { key: 'Escape' } as KeyboardEvent);
314
+ const s2 = backend.sample();
315
+ expect(s2.pointerLocked).toBe(false);
316
+ expect(exitCalled).toBe(true);
317
+ });
318
+
319
+ it('lockProvider.requestLock() returning Promise<void> works with optimistic placement', async () => {
320
+ const fakes = buildLockStateFakes();
321
+ const handle = attachBrowserInputBackend(fakes.canvas, {
322
+ document: fakes.doc,
323
+ window: fakes.win,
324
+ lockProvider: {
325
+ requestLock(): Promise<void> {
326
+ return Promise.resolve();
327
+ },
328
+ exitLock(): void {
329
+ // no-op
330
+ },
331
+ },
332
+ });
333
+ const backend = handle.backend;
334
+
335
+ // D-7 optimistic placement: providerLocked is set true immediately
336
+ // when requestLock() is called, before the promise resolves.
337
+ fakes.store.fire('canvas', 'click', new Event('click'));
338
+
339
+ const s = backend.sample();
340
+ expect(s.pointerLocked).toBe(true);
341
+ // Cleanup: make sure the promise settles.
342
+ await vi.waitFor(() => Promise.resolve(), { timeout: 100 });
343
+ });
344
+ });
345
+
346
+ describe('merged pointerLocked = w3cLocked || providerLocked', () => {
347
+ it('both w3cLocked and providerLocked false → pointerLocked=false', () => {
348
+ const fakes = buildLockStateFakes();
349
+ const handle = attachBrowserInputBackend(fakes.canvas, {
350
+ document: fakes.doc,
351
+ window: fakes.win,
352
+ });
353
+ const s = handle.backend.sample();
354
+ expect(s.pointerLocked).toBe(false);
355
+ });
356
+
357
+ it('w3cLocked=true, providerLocked=false → pointerLocked=true', () => {
358
+ const fakes = buildLockStateFakes();
359
+ const handle = attachBrowserInputBackend(fakes.canvas, {
360
+ document: fakes.doc,
361
+ window: fakes.win,
362
+ });
363
+ firePointerLockChange(fakes, fakes.canvas);
364
+ const s = handle.backend.sample();
365
+ expect(s.pointerLocked).toBe(true);
366
+ });
367
+
368
+ it('w3cLocked=false, providerLocked=true → pointerLocked=true', () => {
369
+ const fakes = buildLockStateFakes();
370
+ const handle = attachBrowserInputBackend(fakes.canvas, {
371
+ document: fakes.doc,
372
+ window: fakes.win,
373
+ lockProvider: {
374
+ requestLock(): void {
375
+ // no-op
376
+ },
377
+ exitLock(): void {
378
+ // no-op
379
+ },
380
+ },
381
+ });
382
+ // Provider path lock via click.
383
+ fakes.store.fire('canvas', 'click', new Event('click'));
384
+ const s = handle.backend.sample();
385
+ expect(s.pointerLocked).toBe(true);
386
+ });
387
+
388
+ it('both w3cLocked=true and providerLocked=true → pointerLocked=true (OR merge)', () => {
389
+ const fakes = buildLockStateFakes();
390
+ const handle = attachBrowserInputBackend(fakes.canvas, {
391
+ document: fakes.doc,
392
+ window: fakes.win,
393
+ lockProvider: {
394
+ requestLock(): void {
395
+ // no-op
396
+ },
397
+ exitLock(): void {
398
+ // no-op
399
+ },
400
+ },
401
+ });
402
+ // Both paths lock.
403
+ firePointerLockChange(fakes, fakes.canvas);
404
+ fakes.store.fire('canvas', 'click', new Event('click'));
405
+ const s = handle.backend.sample();
406
+ expect(s.pointerLocked).toBe(true);
407
+ });
408
+
409
+ it('w3cLocked → false while providerLocked=true keeps pointerLocked=true', () => {
410
+ const fakes = buildLockStateFakes();
411
+ const handle = attachBrowserInputBackend(fakes.canvas, {
412
+ document: fakes.doc,
413
+ window: fakes.win,
414
+ lockProvider: {
415
+ requestLock(): void {
416
+ // no-op
417
+ },
418
+ exitLock(): void {
419
+ // no-op
420
+ },
421
+ },
422
+ });
423
+ // Both paths lock.
424
+ firePointerLockChange(fakes, fakes.canvas);
425
+ fakes.store.fire('canvas', 'click', new Event('click'));
426
+ expect(handle.backend.sample().pointerLocked).toBe(true);
427
+
428
+ // W3C unlocks but provider is still locked.
429
+ firePointerLockChange(fakes, null);
430
+ const s = handle.backend.sample();
431
+ expect(s.pointerLocked).toBe(true);
432
+ });
433
+ });
434
+
435
+ describe('snap.mouse.pointerLocked frozen', () => {
436
+ it('sample().pointerLocked is a boolean (required, not optional)', () => {
437
+ const fakes = buildLockStateFakes();
438
+ const handle = attachBrowserInputBackend(fakes.canvas, {
439
+ document: fakes.doc,
440
+ window: fakes.win,
441
+ });
442
+ const sample: InputBackendSample = handle.backend.sample();
443
+ expect(typeof sample.pointerLocked).toBe('boolean');
444
+ });
445
+
446
+ it('snapshotFromSample writes pointerLocked into snap.mouse.pointerLocked', () => {
447
+ const fakes = buildLockStateFakes();
448
+ const handle = attachBrowserInputBackend(fakes.canvas, {
449
+ document: fakes.doc,
450
+ window: fakes.win,
451
+ });
452
+ // Lock via W3C path.
453
+ firePointerLockChange(fakes, fakes.canvas);
454
+ const sample = handle.backend.sample();
455
+ expect(sample.pointerLocked).toBe(true);
456
+
457
+ const snap = snapshotFromSample(sample);
458
+ expect(snap.mouse.pointerLocked).toBe(true);
459
+ });
460
+
461
+ it('snap.mouse.pointerLocked is false in empty snapshot from createInputSnapshot', () => {
462
+ // This will be tested after w6 when createInputSnapshot includes pointerLocked.
463
+ // For now, we verify the snapshot propagation contract.
464
+ const snap = snapshotFromSample({
465
+ downKeys: new Set(),
466
+ upKeys: new Set(),
467
+ buttons: [false, false, false],
468
+ movementX: 0,
469
+ movementY: 0,
470
+ wheelDelta: 0,
471
+ focused: true,
472
+ pointerLocked: false,
473
+ });
474
+ expect(snap.mouse.pointerLocked).toBe(false);
475
+ });
476
+
477
+ it('snap.mouse.pointerLocked is immutable (frozen) across re-reads', () => {
478
+ const snap = snapshotFromSample({
479
+ downKeys: new Set(),
480
+ upKeys: new Set(),
481
+ buttons: [false, false, false],
482
+ movementX: 0,
483
+ movementY: 0,
484
+ wheelDelta: 0,
485
+ focused: true,
486
+ pointerLocked: true,
487
+ });
488
+ expect(snap.mouse.pointerLocked).toBe(true);
489
+ expect(snap.mouse.pointerLocked).toBe(true); // re-read stable
490
+ });
491
+ });
492
+
493
+ describe('detach cleans up pointerlockchange listener', () => {
494
+ it('detach removes pointerlockchange listener from document', () => {
495
+ const fakes = buildLockStateFakes();
496
+ const handle = attachBrowserInputBackend(fakes.canvas, {
497
+ document: fakes.doc,
498
+ window: fakes.win,
499
+ });
500
+
501
+ // Before detach: listener exists.
502
+ const beforeDetachCount = fakes.store.count();
503
+ expect(beforeDetachCount).toBeGreaterThan(0);
504
+
505
+ handle();
506
+
507
+ // After detach: all listeners removed (count should be 0).
508
+ expect(fakes.store.count()).toBe(0);
509
+ });
510
+
511
+ it('pointerlockchange after detach does not affect sample', () => {
512
+ const fakes = buildLockStateFakes();
513
+ const handle = attachBrowserInputBackend(fakes.canvas, {
514
+ document: fakes.doc,
515
+ window: fakes.win,
516
+ });
517
+ // Lock, then detach.
518
+ firePointerLockChange(fakes, fakes.canvas);
519
+ expect(handle.backend.sample().pointerLocked).toBe(true);
520
+ handle();
521
+
522
+ // After detach, unlock event should not affect anything.
523
+ firePointerLockChange(fakes, null);
524
+ // Note: sample() after detach is still callable (no throw),
525
+ // but the backend should no longer track lock state changes.
526
+ const s = handle.backend.sample();
527
+ // pointerLocked may be true (stale from before detach) or false (reset on detach),
528
+ // but the key assertion is that sample() doesn't throw after detach.
529
+ expect(typeof s.pointerLocked).toBe('boolean');
530
+ });
531
+ });
532
+
533
+ // Regression: setPointerCapture must NOT be called while pointer lock is active.
534
+ // Pointer capture + pointer lock are mutually exclusive (W3C) — calling
535
+ // setPointerCapture on a locked element throws InvalidStateError. This is what
536
+ // crashed the game template on the 2nd click (1st click locked, 2nd click's
537
+ // onPointerDown captured → throw). The backend now skips capture while locked.
538
+ describe('setPointerCapture vs pointer lock (InvalidStateError regression)', () => {
539
+ function firePointerDown(fakes: LockStateFakes, pointerId = 1): void {
540
+ fakes.store.fire('canvas', 'pointerdown', {
541
+ pointerType: 'mouse',
542
+ button: 0,
543
+ pointerId,
544
+ pressure: 0.5,
545
+ clientX: 10,
546
+ clientY: 10,
547
+ movementX: 0,
548
+ movementY: 0,
549
+ } as unknown as Event);
550
+ }
551
+
552
+ it('does NOT call setPointerCapture on pointerdown while locked', () => {
553
+ const fakes = buildLockStateFakes();
554
+ // Make the fake behave like a real browser: throw if captured while locked.
555
+ const capture = vi.fn((_id: number) => {
556
+ if (fakes.doc.pointerLockElement === fakes.canvas) {
557
+ throw new DOMException('capture while locked', 'InvalidStateError');
558
+ }
559
+ });
560
+ (fakes.canvas as unknown as { setPointerCapture: typeof capture }).setPointerCapture =
561
+ capture;
562
+
563
+ const handle = attachBrowserInputBackend(fakes.canvas, {
564
+ document: fakes.doc,
565
+ window: fakes.win,
566
+ });
567
+
568
+ // Not locked yet: pointerdown SHOULD capture (drag coherence path).
569
+ firePointerDown(fakes, 1);
570
+ expect(capture).toHaveBeenCalledTimes(1);
571
+
572
+ // Browser locks onto the canvas, then another pointerdown (the "shoot").
573
+ firePointerLockChange(fakes, fakes.canvas);
574
+ expect(handle.backend.sample().pointerLocked).toBe(true);
575
+ // Must NOT throw and must NOT call capture again while locked.
576
+ expect(() => firePointerDown(fakes, 1)).not.toThrow();
577
+ expect(capture).toHaveBeenCalledTimes(1);
578
+ });
579
+
580
+ it('resumes capturing after pointer lock is released', () => {
581
+ const fakes = buildLockStateFakes();
582
+ const capture = vi.fn((_id: number) => {
583
+ if (fakes.doc.pointerLockElement === fakes.canvas) {
584
+ throw new DOMException('capture while locked', 'InvalidStateError');
585
+ }
586
+ });
587
+ (fakes.canvas as unknown as { setPointerCapture: typeof capture }).setPointerCapture =
588
+ capture;
589
+
590
+ const handle = attachBrowserInputBackend(fakes.canvas, {
591
+ document: fakes.doc,
592
+ window: fakes.win,
593
+ });
594
+ firePointerLockChange(fakes, fakes.canvas); // lock
595
+ firePointerDown(fakes, 1); // skipped while locked
596
+ expect(capture).toHaveBeenCalledTimes(0);
597
+
598
+ firePointerLockChange(fakes, null); // unlock
599
+ expect(handle.backend.sample().pointerLocked).toBe(false);
600
+ firePointerDown(fakes, 1); // capture allowed again
601
+ expect(capture).toHaveBeenCalledTimes(1);
602
+ });
603
+ });
604
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createCanvasInputBoundary } from '../canvas-input-boundary';
3
+ import {
4
+ createEmptyInputBackendSample,
5
+ type InputBackend,
6
+ type InputBackendSample,
7
+ } from '../input-snapshot';
8
+
9
+ function sample(keys: readonly string[] = []): InputBackendSample {
10
+ return {
11
+ downKeys: new Set(keys),
12
+ upKeys: new Set(),
13
+ buttons: [false, false, false],
14
+ movementX: 0,
15
+ movementY: 0,
16
+ wheelDelta: 0,
17
+ focused: true,
18
+ pointerLocked: false,
19
+ };
20
+ }
21
+
22
+ describe('CanvasInputBoundary', () => {
23
+ it('derives inactive consumers from the input empty-sample owner', () => {
24
+ const source: InputBackend = { sample: () => sample(['w']), detach() {} };
25
+ const boundary = createCanvasInputBoundary(source);
26
+
27
+ expect(boundary.game.sample()).toEqual(createEmptyInputBackendSample());
28
+ });
29
+
30
+ it('routes each browser frame only to the leased consumer', () => {
31
+ const source: InputBackend = { sample: () => sample(['w']), detach() {} };
32
+ const boundary = createCanvasInputBoundary(source);
33
+
34
+ expect(boundary.editor.sample().downKeys.has('w')).toBe(true);
35
+ expect(boundary.game.sample().downKeys.has('w')).toBe(false);
36
+
37
+ boundary.grantGame();
38
+ expect(boundary.editor.sample().downKeys.has('w')).toBe(false);
39
+ expect(boundary.game.sample().downKeys.has('w')).toBe(true);
40
+ });
41
+
42
+ it('clears source state and pointer lock for every revocation', () => {
43
+ let clears = 0;
44
+ const locks: boolean[] = [];
45
+ const source: InputBackend = {
46
+ sample: () => sample(['w']),
47
+ clear: () => {
48
+ clears++;
49
+ },
50
+ setPointerLockAllowed: (allowed) => {
51
+ locks.push(allowed);
52
+ },
53
+ detach() {},
54
+ };
55
+ const boundary = createCanvasInputBoundary(source);
56
+ boundary.grantGame();
57
+ boundary.revokeGame();
58
+ boundary.revokeGame();
59
+
60
+ expect(boundary.owner()).toBe('editor');
61
+ expect(clears).toBe(3);
62
+ expect(locks.filter((allowed) => !allowed).length).toBeGreaterThanOrEqual(3);
63
+ expect(boundary.game.sample().downKeys.size).toBe(0);
64
+ });
65
+ });