@forgeax/engine-input 0.1.2

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,425 @@
1
+ // browser-backend-lock-error.test.ts -- M1 w4: onLockError unit tests.
2
+ //
3
+ // Covers: W3C rejection → onLockError (no longer silently swallowed),
4
+ // provider reject → onLockError + providerLocked rollback.
5
+ //
6
+ // charter awareness:
7
+ // P3 explicit failure -- lock failures must produce structured signals,
8
+ // never silently swallowed (constraint 7).
9
+ // F1 single-entry indexability -- all error tests in one file.
10
+
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { attachBrowserInputBackend } from '../browser-backend';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Tests
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe('browser-backend-lock-error.test.ts (w4)', () => {
19
+ describe('W3C requestPointerLock rejection → onLockError', () => {
20
+ it('W3C rejection calls onLockError with { path: "w3c", cause }', () => {
21
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
22
+ const makeTarget = (label: string) => ({
23
+ addEventListener(kind: string, handler: EventListener): void {
24
+ let perTarget = listeners.get(label);
25
+ if (!perTarget) {
26
+ perTarget = new Map();
27
+ listeners.set(label, perTarget);
28
+ }
29
+ let set = perTarget.get(kind);
30
+ if (!set) {
31
+ set = new Set();
32
+ perTarget.set(kind, set);
33
+ }
34
+ set.add(handler);
35
+ },
36
+ removeEventListener(kind: string, handler: EventListener): void {
37
+ listeners.get(label)?.get(kind)?.delete(handler);
38
+ },
39
+ });
40
+
41
+ const pointerLockEl: Element | null = null;
42
+ const rejectionError = new Error('WrongDocumentError');
43
+ const lockErrorCalls: Array<{ path: string; cause: unknown }> = [];
44
+
45
+ const canvas = {
46
+ ...makeTarget('canvas'),
47
+ requestPointerLock(): Promise<void> {
48
+ return Promise.reject(rejectionError);
49
+ },
50
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
51
+ width: 800,
52
+ height: 600,
53
+ style: { touchAction: '' },
54
+ setPointerCapture: () => {},
55
+ } as unknown as HTMLCanvasElement;
56
+
57
+ const doc = {
58
+ ...makeTarget('document'),
59
+ hasFocus() {
60
+ return true;
61
+ },
62
+ visibilityState: 'visible',
63
+ get pointerLockElement() {
64
+ return pointerLockEl;
65
+ },
66
+ exitPointerLock() {},
67
+ } as unknown as Document;
68
+
69
+ const win = makeTarget('window') as unknown as Window;
70
+
71
+ attachBrowserInputBackend(canvas, {
72
+ document: doc,
73
+ window: win,
74
+ onLockError: (detail) => {
75
+ lockErrorCalls.push(detail);
76
+ },
77
+ });
78
+
79
+ // Click triggers requestPointerLock which rejects.
80
+ const clickH = listeners.get('canvas')?.get('click');
81
+ if (clickH) {
82
+ const ev = new Event('click');
83
+ for (const h of clickH) h(ev as Event);
84
+ }
85
+
86
+ // Wait for microtask to resolve promise rejection.
87
+ return vi.waitFor(
88
+ () => {
89
+ expect(lockErrorCalls.length).toBe(1);
90
+ expect(lockErrorCalls[0]?.path).toBe('w3c');
91
+ expect(lockErrorCalls[0]?.cause).toBe(rejectionError);
92
+ },
93
+ { timeout: 200 },
94
+ );
95
+ });
96
+
97
+ it('W3C rejection without onLockError provided does NOT throw (backend is resilient)', () => {
98
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
99
+ const makeTarget = (label: string) => ({
100
+ addEventListener(kind: string, handler: EventListener): void {
101
+ let perTarget = listeners.get(label);
102
+ if (!perTarget) {
103
+ perTarget = new Map();
104
+ listeners.set(label, perTarget);
105
+ }
106
+ let set = perTarget.get(kind);
107
+ if (!set) {
108
+ set = new Set();
109
+ perTarget.set(kind, set);
110
+ }
111
+ set.add(handler);
112
+ },
113
+ removeEventListener(kind: string, handler: EventListener): void {
114
+ listeners.get(label)?.get(kind)?.delete(handler);
115
+ },
116
+ });
117
+
118
+ const pointerLockEl: Element | null = null;
119
+
120
+ const canvas = {
121
+ ...makeTarget('canvas'),
122
+ requestPointerLock(): Promise<void> {
123
+ return Promise.reject(new Error('SecurityError'));
124
+ },
125
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
126
+ width: 800,
127
+ height: 600,
128
+ style: { touchAction: '' },
129
+ setPointerCapture: () => {},
130
+ } as unknown as HTMLCanvasElement;
131
+
132
+ const doc = {
133
+ ...makeTarget('document'),
134
+ hasFocus() {
135
+ return true;
136
+ },
137
+ visibilityState: 'visible',
138
+ get pointerLockElement() {
139
+ return pointerLockEl;
140
+ },
141
+ exitPointerLock() {},
142
+ } as unknown as Document;
143
+
144
+ const win = makeTarget('window') as unknown as Window;
145
+
146
+ attachBrowserInputBackend(canvas, {
147
+ document: doc,
148
+ window: win,
149
+ // No onLockError provided -- should not throw.
150
+ });
151
+
152
+ // Click triggers rejection.
153
+ const clickH = listeners.get('canvas')?.get('click');
154
+ if (clickH) {
155
+ const ev = new Event('click');
156
+ for (const h of clickH) h(ev as Event);
157
+ }
158
+
159
+ // The promise rejection should be caught, not unhandled.
160
+ // We verify by waiting a microtask — no throw should occur.
161
+ return vi.waitFor(
162
+ () => {
163
+ // If we got here without an unhandled rejection, the test passes.
164
+ expect(true).toBe(true);
165
+ },
166
+ { timeout: 200 },
167
+ );
168
+ });
169
+ });
170
+
171
+ describe('provider requestLock reject → onLockError + rollback', () => {
172
+ it('provider requestLock reject calls onLockError with { path: "provider", cause }', async () => {
173
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
174
+ const makeTarget = (label: string) => ({
175
+ addEventListener(kind: string, handler: EventListener): void {
176
+ let perTarget = listeners.get(label);
177
+ if (!perTarget) {
178
+ perTarget = new Map();
179
+ listeners.set(label, perTarget);
180
+ }
181
+ let set = perTarget.get(kind);
182
+ if (!set) {
183
+ set = new Set();
184
+ perTarget.set(kind, set);
185
+ }
186
+ set.add(handler);
187
+ },
188
+ removeEventListener(kind: string, handler: EventListener): void {
189
+ listeners.get(label)?.get(kind)?.delete(handler);
190
+ },
191
+ });
192
+
193
+ const pointerLockEl: Element | null = null;
194
+ const providerError = new Error('Tauri invoke failed');
195
+ const lockErrorCalls: Array<{ path: string; cause: unknown }> = [];
196
+
197
+ const canvas = {
198
+ ...makeTarget('canvas'),
199
+ requestPointerLock() {},
200
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
201
+ width: 800,
202
+ height: 600,
203
+ style: { touchAction: '' },
204
+ setPointerCapture: () => {},
205
+ } as unknown as HTMLCanvasElement;
206
+
207
+ const doc = {
208
+ ...makeTarget('document'),
209
+ hasFocus() {
210
+ return true;
211
+ },
212
+ visibilityState: 'visible',
213
+ get pointerLockElement() {
214
+ return pointerLockEl;
215
+ },
216
+ exitPointerLock() {},
217
+ } as unknown as Document;
218
+
219
+ const win = makeTarget('window') as unknown as Window;
220
+
221
+ const handle = attachBrowserInputBackend(canvas, {
222
+ document: doc,
223
+ window: win,
224
+ lockProvider: {
225
+ requestLock(): Promise<void> {
226
+ return Promise.reject(providerError);
227
+ },
228
+ exitLock() {},
229
+ },
230
+ onLockError: (detail) => {
231
+ lockErrorCalls.push(detail);
232
+ },
233
+ });
234
+ const backend = handle.backend;
235
+
236
+ // Click triggers provider requestLock which rejects.
237
+ const clickH = listeners.get('canvas')?.get('click');
238
+ if (clickH) {
239
+ const ev = new Event('click');
240
+ for (const h of clickH) h(ev as Event);
241
+ }
242
+
243
+ await vi.waitFor(
244
+ () => {
245
+ expect(lockErrorCalls.length).toBe(1);
246
+ expect(lockErrorCalls[0]?.path).toBe('provider');
247
+ expect(lockErrorCalls[0]?.cause).toBe(providerError);
248
+ },
249
+ { timeout: 200 },
250
+ );
251
+
252
+ // providerLocked should be rolled back.
253
+ const s = backend.sample();
254
+ expect(s.pointerLocked).toBe(false);
255
+ });
256
+
257
+ it('provider requestLock throw calls onLockError and rolls back providerLocked', async () => {
258
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
259
+ const makeTarget = (label: string) => ({
260
+ addEventListener(kind: string, handler: EventListener): void {
261
+ let perTarget = listeners.get(label);
262
+ if (!perTarget) {
263
+ perTarget = new Map();
264
+ listeners.set(label, perTarget);
265
+ }
266
+ let set = perTarget.get(kind);
267
+ if (!set) {
268
+ set = new Set();
269
+ perTarget.set(kind, set);
270
+ }
271
+ set.add(handler);
272
+ },
273
+ removeEventListener(kind: string, handler: EventListener): void {
274
+ listeners.get(label)?.get(kind)?.delete(handler);
275
+ },
276
+ });
277
+
278
+ const pointerLockEl: Element | null = null;
279
+ const syncError = new Error('postMessage failed');
280
+ const lockErrorCalls: Array<{ path: string; cause: unknown }> = [];
281
+
282
+ const canvas = {
283
+ ...makeTarget('canvas'),
284
+ requestPointerLock() {},
285
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
286
+ width: 800,
287
+ height: 600,
288
+ style: { touchAction: '' },
289
+ setPointerCapture: () => {},
290
+ } as unknown as HTMLCanvasElement;
291
+
292
+ const doc = {
293
+ ...makeTarget('document'),
294
+ hasFocus() {
295
+ return true;
296
+ },
297
+ visibilityState: 'visible',
298
+ get pointerLockElement() {
299
+ return pointerLockEl;
300
+ },
301
+ exitPointerLock() {},
302
+ } as unknown as Document;
303
+
304
+ const win = makeTarget('window') as unknown as Window;
305
+
306
+ const handle = attachBrowserInputBackend(canvas, {
307
+ document: doc,
308
+ window: win,
309
+ lockProvider: {
310
+ requestLock(): void {
311
+ throw syncError;
312
+ },
313
+ exitLock() {},
314
+ },
315
+ onLockError: (detail) => {
316
+ lockErrorCalls.push(detail);
317
+ },
318
+ });
319
+ const backend = handle.backend;
320
+
321
+ // Click triggers provider requestLock which throws synchronously.
322
+ const clickH = listeners.get('canvas')?.get('click');
323
+ if (clickH) {
324
+ const ev = new Event('click');
325
+ for (const h of clickH) h(ev as Event);
326
+ }
327
+
328
+ expect(lockErrorCalls.length).toBe(1);
329
+ expect(lockErrorCalls[0]?.path).toBe('provider');
330
+ expect(lockErrorCalls[0]?.cause).toBe(syncError);
331
+
332
+ // providerLocked should be rolled back.
333
+ const s = backend.sample();
334
+ expect(s.pointerLocked).toBe(false);
335
+ });
336
+
337
+ it('provider exitLock throw calls onLockError (does not affect lock cleanup)', async () => {
338
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
339
+ const makeTarget = (label: string) => ({
340
+ addEventListener(kind: string, handler: EventListener): void {
341
+ let perTarget = listeners.get(label);
342
+ if (!perTarget) {
343
+ perTarget = new Map();
344
+ listeners.set(label, perTarget);
345
+ }
346
+ let set = perTarget.get(kind);
347
+ if (!set) {
348
+ set = new Set();
349
+ perTarget.set(kind, set);
350
+ }
351
+ set.add(handler);
352
+ },
353
+ removeEventListener(kind: string, handler: EventListener): void {
354
+ listeners.get(label)?.get(kind)?.delete(handler);
355
+ },
356
+ });
357
+
358
+ const pointerLockEl: Element | null = null;
359
+ const exitError = new Error('exitLock failed');
360
+ const lockErrorCalls: Array<{ path: string; cause: unknown }> = [];
361
+
362
+ const canvas = {
363
+ ...makeTarget('canvas'),
364
+ requestPointerLock() {},
365
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
366
+ width: 800,
367
+ height: 600,
368
+ style: { touchAction: '' },
369
+ setPointerCapture: () => {},
370
+ } as unknown as HTMLCanvasElement;
371
+
372
+ const doc = {
373
+ ...makeTarget('document'),
374
+ hasFocus() {
375
+ return true;
376
+ },
377
+ visibilityState: 'visible',
378
+ get pointerLockElement() {
379
+ return pointerLockEl;
380
+ },
381
+ exitPointerLock() {},
382
+ } as unknown as Document;
383
+
384
+ const win = makeTarget('window') as unknown as Window;
385
+
386
+ const handle = attachBrowserInputBackend(canvas, {
387
+ document: doc,
388
+ window: win,
389
+ lockProvider: {
390
+ requestLock() {},
391
+ exitLock(): void {
392
+ throw exitError;
393
+ },
394
+ },
395
+ onLockError: (detail) => {
396
+ lockErrorCalls.push(detail);
397
+ },
398
+ });
399
+ const backend = handle.backend;
400
+
401
+ // Lock via provider path.
402
+ const clickH = listeners.get('canvas')?.get('click');
403
+ if (clickH) {
404
+ const ev = new Event('click');
405
+ for (const h of clickH) h(ev as Event);
406
+ }
407
+ expect(backend.sample().pointerLocked).toBe(true);
408
+
409
+ // ESC triggers exitLock which throws.
410
+ const keyH = listeners.get('window')?.get('keydown');
411
+ if (keyH) {
412
+ const ev = { key: 'Escape' } as KeyboardEvent;
413
+ for (const h of keyH) h(ev as Event);
414
+ }
415
+
416
+ expect(lockErrorCalls.length).toBe(1);
417
+ expect(lockErrorCalls[0]?.path).toBe('provider');
418
+ expect(lockErrorCalls[0]?.cause).toBe(exitError);
419
+
420
+ // providerLocked should still be cleared despite exitLock throw.
421
+ const s = backend.sample();
422
+ expect(s.pointerLocked).toBe(false);
423
+ });
424
+ });
425
+ });
@@ -0,0 +1,283 @@
1
+ // browser-backend-lock-gate.test.ts -- M1 w2: gate synthesis unit tests.
2
+ //
3
+ // Covers: gameGate x hostPredicate four-quadrant matrix,
4
+ // setPointerLockAllowed(false) immediate release on both paths.
5
+ //
6
+ // charter awareness:
7
+ // F1 single-entry indexability -- all gate tests in one file
8
+ // P3 explicit failure -- each assertion produces a clear diagnostic
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { attachBrowserInputBackend } from '../browser-backend';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helper: build fake env with shared document listeners.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function buildEnvForClickTest(opts?: {
18
+ hostPredicate?: (() => boolean) | undefined;
19
+ lockProvider?: { requestLock: () => void; exitLock: () => void } | undefined;
20
+ }): {
21
+ backend: ReturnType<typeof attachBrowserInputBackend>['backend'];
22
+ fireClick(): void;
23
+ requestPointerLockCalls: { count: number };
24
+ requestLockCalls: { count: number };
25
+ exitLockCalls: { count: number };
26
+ exitPointerLockCalls: { count: number };
27
+ setPointerLockElement(el: Element | null): void;
28
+ canvas: HTMLCanvasElement;
29
+ } {
30
+ const listeners = new Map<string, Map<string, Set<EventListener>>>();
31
+ const makeTarget = (label: string) => ({
32
+ addEventListener(kind: string, handler: EventListener): void {
33
+ let perTarget = listeners.get(label);
34
+ if (!perTarget) {
35
+ perTarget = new Map();
36
+ listeners.set(label, perTarget);
37
+ }
38
+ let set = perTarget.get(kind);
39
+ if (!set) {
40
+ set = new Set();
41
+ perTarget.set(kind, set);
42
+ }
43
+ set.add(handler);
44
+ },
45
+ removeEventListener(kind: string, handler: EventListener): void {
46
+ listeners.get(label)?.get(kind)?.delete(handler);
47
+ },
48
+ });
49
+
50
+ let pointerLockEl: Element | null = null;
51
+ const requestPointerLockCalls = { count: 0 };
52
+ const exitPointerLockCalls = { count: 0 };
53
+ const requestLockCalls = { count: 0 };
54
+ const exitLockCalls = { count: 0 };
55
+
56
+ const canvas = {
57
+ ...makeTarget('canvas'),
58
+ requestPointerLock() {
59
+ requestPointerLockCalls.count += 1;
60
+ },
61
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) as DOMRect,
62
+ width: 800,
63
+ height: 600,
64
+ style: { touchAction: '' },
65
+ setPointerCapture: () => {},
66
+ } as unknown as HTMLCanvasElement;
67
+
68
+ const doc = {
69
+ ...makeTarget('document'),
70
+ hasFocus() {
71
+ return true;
72
+ },
73
+ visibilityState: 'visible',
74
+ get pointerLockElement() {
75
+ return pointerLockEl;
76
+ },
77
+ exitPointerLock() {
78
+ exitPointerLockCalls.count += 1;
79
+ pointerLockEl = null;
80
+ const hs = listeners.get('document')?.get('pointerlockchange');
81
+ if (hs) {
82
+ const ev = new Event('pointerlockchange');
83
+ for (const h of hs) h(ev as Event);
84
+ }
85
+ },
86
+ } as unknown as Document & { exitPointerLock(): void };
87
+
88
+ const win = makeTarget('window') as unknown as Window;
89
+
90
+ // Build options with exactOptionalPropertyTypes-safe handling.
91
+ const backendOpts: Record<string, unknown> = {
92
+ document: doc,
93
+ window: win,
94
+ };
95
+ if (opts?.hostPredicate !== undefined) {
96
+ backendOpts.pointerLockAllowed = opts.hostPredicate;
97
+ }
98
+ if (opts?.lockProvider !== undefined) {
99
+ backendOpts.lockProvider = opts.lockProvider;
100
+ }
101
+
102
+ const handle = attachBrowserInputBackend(
103
+ canvas,
104
+ backendOpts as Parameters<typeof attachBrowserInputBackend>[1],
105
+ );
106
+
107
+ return {
108
+ backend: handle.backend,
109
+ canvas,
110
+ fireClick() {
111
+ const handlers = listeners.get('canvas')?.get('click');
112
+ if (handlers) {
113
+ const ev = new Event('click');
114
+ for (const h of handlers) h(ev as Event);
115
+ }
116
+ },
117
+ requestPointerLockCalls,
118
+ exitPointerLockCalls,
119
+ requestLockCalls,
120
+ exitLockCalls,
121
+ setPointerLockElement(el) {
122
+ pointerLockEl = el;
123
+ const hs = listeners.get('document')?.get('pointerlockchange');
124
+ if (hs) {
125
+ const ev = new Event('pointerlockchange');
126
+ for (const h of hs) h(ev as Event);
127
+ }
128
+ },
129
+ };
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Tests
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('browser-backend-lock-gate.test.ts (w2)', () => {
137
+ describe('gate four-quadrant matrix', () => {
138
+ it('gameGate=true (default), hostPredicate=true -> lock requested (W3C path)', () => {
139
+ const env = buildEnvForClickTest({
140
+ hostPredicate: () => true,
141
+ });
142
+ env.fireClick();
143
+ expect(env.requestPointerLockCalls.count).toBe(1);
144
+ expect(env.backend.sample().pointerLocked).toBe(false); // pointerlockchange not fired yet
145
+ });
146
+
147
+ it('gameGate=true (default), hostPredicate=false -> no lock', () => {
148
+ const env = buildEnvForClickTest({
149
+ hostPredicate: () => false,
150
+ });
151
+ env.fireClick();
152
+ expect(env.requestPointerLockCalls.count).toBe(0);
153
+ expect(env.backend.sample().pointerLocked).toBe(false);
154
+ });
155
+
156
+ it('gameGate=false (setPointerLockAllowed(false)), hostPredicate=true -> no lock', () => {
157
+ const env = buildEnvForClickTest({
158
+ hostPredicate: () => true,
159
+ });
160
+ env.backend.setPointerLockAllowed?.(false);
161
+ env.fireClick();
162
+ expect(env.requestPointerLockCalls.count).toBe(0);
163
+ expect(env.backend.sample().pointerLocked).toBe(false);
164
+ });
165
+
166
+ it('gameGate=false, hostPredicate=false -> no lock', () => {
167
+ const env = buildEnvForClickTest({
168
+ hostPredicate: () => false,
169
+ });
170
+ env.backend.setPointerLockAllowed?.(false);
171
+ env.fireClick();
172
+ expect(env.requestPointerLockCalls.count).toBe(0);
173
+ expect(env.backend.sample().pointerLocked).toBe(false);
174
+ });
175
+
176
+ it('gameGate reverts to true after setPointerLockAllowed(true)', () => {
177
+ const env = buildEnvForClickTest({
178
+ hostPredicate: () => true,
179
+ });
180
+ env.backend.setPointerLockAllowed?.(false);
181
+ env.fireClick();
182
+ expect(env.requestPointerLockCalls.count).toBe(0);
183
+
184
+ env.backend.setPointerLockAllowed?.(true);
185
+ env.fireClick();
186
+ expect(env.requestPointerLockCalls.count).toBe(1);
187
+ });
188
+ });
189
+
190
+ describe('gate with lockProvider path', () => {
191
+ it('gate passes + lockProvider present -> requestLock called, not W3C', () => {
192
+ const requestLockCalls = { count: 0 };
193
+ const env = buildEnvForClickTest({
194
+ hostPredicate: () => true,
195
+ lockProvider: {
196
+ requestLock() {
197
+ requestLockCalls.count += 1;
198
+ },
199
+ exitLock() {},
200
+ },
201
+ });
202
+ env.fireClick();
203
+ expect(requestLockCalls.count).toBe(1);
204
+ expect(env.requestPointerLockCalls.count).toBe(0);
205
+ expect(env.backend.sample().pointerLocked).toBe(true); // D-7 optimistic
206
+ });
207
+
208
+ it('gate blocked + lockProvider present -> neither requestLock nor W3C called', () => {
209
+ const requestLockCalls = { count: 0 };
210
+ const env = buildEnvForClickTest({
211
+ hostPredicate: () => false,
212
+ lockProvider: {
213
+ requestLock() {
214
+ requestLockCalls.count += 1;
215
+ },
216
+ exitLock() {},
217
+ },
218
+ });
219
+ env.fireClick();
220
+ expect(requestLockCalls.count).toBe(0);
221
+ expect(env.requestPointerLockCalls.count).toBe(0);
222
+ expect(env.backend.sample().pointerLocked).toBe(false);
223
+ });
224
+ });
225
+
226
+ describe('setPointerLockAllowed(false) immediate release', () => {
227
+ it('W3C path: setPointerLockAllowed(false) while locked -> exitPointerLock called', () => {
228
+ const env = buildEnvForClickTest({
229
+ hostPredicate: () => true,
230
+ });
231
+ // Lock via W3C path: click + pointerlockchange simulation.
232
+ env.fireClick();
233
+ env.setPointerLockElement(env.canvas);
234
+ expect(env.backend.sample().pointerLocked).toBe(true);
235
+
236
+ // Now release.
237
+ env.backend.setPointerLockAllowed?.(false);
238
+ expect(env.exitPointerLockCalls.count).toBe(1);
239
+ expect(env.backend.sample().pointerLocked).toBe(false);
240
+ });
241
+
242
+ it('provider path: setPointerLockAllowed(false) while locked -> exitLock called', () => {
243
+ const exitLockCalls = { count: 0 };
244
+ const env = buildEnvForClickTest({
245
+ hostPredicate: () => true,
246
+ lockProvider: {
247
+ requestLock() {},
248
+ exitLock() {
249
+ exitLockCalls.count += 1;
250
+ },
251
+ },
252
+ });
253
+ env.fireClick();
254
+ expect(env.backend.sample().pointerLocked).toBe(true);
255
+
256
+ env.backend.setPointerLockAllowed?.(false);
257
+ expect(exitLockCalls.count).toBe(1);
258
+ expect(env.backend.sample().pointerLocked).toBe(false);
259
+ });
260
+
261
+ it('setPointerLockAllowed(false) when not locked -> no-op', () => {
262
+ const env = buildEnvForClickTest();
263
+ env.backend.setPointerLockAllowed?.(false);
264
+ expect(env.exitPointerLockCalls.count).toBe(0);
265
+ });
266
+ });
267
+
268
+ describe('hostPredicate is evaluated live per click', () => {
269
+ it('predicate toggle mid-session switches gate behavior', () => {
270
+ let gateAllowed = false;
271
+ const env = buildEnvForClickTest({
272
+ hostPredicate: () => gateAllowed,
273
+ });
274
+
275
+ env.fireClick();
276
+ expect(env.requestPointerLockCalls.count).toBe(0);
277
+
278
+ gateAllowed = true;
279
+ env.fireClick();
280
+ expect(env.requestPointerLockCalls.count).toBe(1);
281
+ });
282
+ });
283
+ });