@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.
- package/LICENSE +202 -0
- package/README.md +312 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/browser-backend-dual-instance.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-dual-instance.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-focus-loss.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-focus-loss.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-error.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-error.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-gate.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-gate.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-release.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-release.test.d.ts.map +1 -0
- package/dist/__tests__/browser-backend-lock-state.test.d.ts +2 -0
- package/dist/__tests__/browser-backend-lock-state.test.d.ts.map +1 -0
- package/dist/__tests__/canvas-input-boundary.test.d.ts +2 -0
- package/dist/__tests__/canvas-input-boundary.test.d.ts.map +1 -0
- package/dist/__tests__/canvas-input-owner.test-d.d.ts +2 -0
- package/dist/__tests__/canvas-input-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/composite-backend.test.d.ts +2 -0
- package/dist/__tests__/composite-backend.test.d.ts.map +1 -0
- package/dist/__tests__/controller-platform-owner.test-d.d.ts +2 -0
- package/dist/__tests__/controller-platform-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/input-snapshot.test-d.d.ts +2 -0
- package/dist/__tests__/input-snapshot.test-d.d.ts.map +1 -0
- package/dist/__tests__/input-snapshot.test.d.ts +2 -0
- package/dist/__tests__/input-snapshot.test.d.ts.map +1 -0
- package/dist/__tests__/swipe-direction-owner.test-d.d.ts +2 -0
- package/dist/__tests__/swipe-direction-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/ui-input-matrix.test.d.ts +2 -0
- package/dist/__tests__/ui-input-matrix.test.d.ts.map +1 -0
- package/dist/__tests__/ui-ownership-edge.test.d.ts +2 -0
- package/dist/__tests__/ui-ownership-edge.test.d.ts.map +1 -0
- package/dist/__tests__/ui-ownership.test.d.ts +2 -0
- package/dist/__tests__/ui-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/ui-reset-idempotence.test.d.ts +2 -0
- package/dist/__tests__/ui-reset-idempotence.test.d.ts.map +1 -0
- package/dist/__tests__/ui-reset.test.d.ts +2 -0
- package/dist/__tests__/ui-reset.test.d.ts.map +1 -0
- package/dist/action-state.d.ts +146 -0
- package/dist/action-state.d.ts.map +1 -0
- package/dist/browser-backend.d.ts +121 -0
- package/dist/browser-backend.d.ts.map +1 -0
- package/dist/canvas-input-boundary.d.ts +13 -0
- package/dist/canvas-input-boundary.d.ts.map +1 -0
- package/dist/composite-backend.d.ts +34 -0
- package/dist/composite-backend.d.ts.map +1 -0
- package/dist/controller-db-data.d.ts +5 -0
- package/dist/controller-db-data.d.ts.map +1 -0
- package/dist/controller-db-data.mjs +12 -0
- package/dist/controller-db-data.mjs.map +1 -0
- package/dist/controller-db.d.ts +70 -0
- package/dist/controller-db.d.ts.map +1 -0
- package/dist/controller-db.mjs +119 -0
- package/dist/controller-db.mjs.map +1 -0
- package/dist/frame-start-scan-system.d.ts +37 -0
- package/dist/frame-start-scan-system.d.ts.map +1 -0
- package/dist/gamepad-frame.d.ts +42 -0
- package/dist/gamepad-frame.d.ts.map +1 -0
- package/dist/gesture-recognizer.d.ts +145 -0
- package/dist/gesture-recognizer.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1502 -0
- package/dist/index.mjs.map +1 -0
- package/dist/input-snapshot.d.ts +420 -0
- package/dist/input-snapshot.d.ts.map +1 -0
- package/dist/plugin-service.d.ts +11 -0
- package/dist/plugin-service.d.ts.map +1 -0
- package/dist/ui-ownership.d.ts +17 -0
- package/dist/ui-ownership.d.ts.map +1 -0
- package/dist/virtual-joystick.d.ts +39 -0
- package/dist/virtual-joystick.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/browser-backend-dual-instance.test.ts +379 -0
- package/src/__tests__/browser-backend-focus-loss.test.ts +239 -0
- package/src/__tests__/browser-backend-lock-error.test.ts +425 -0
- package/src/__tests__/browser-backend-lock-gate.test.ts +283 -0
- package/src/__tests__/browser-backend-lock-release.test.ts +641 -0
- package/src/__tests__/browser-backend-lock-state.test.ts +604 -0
- package/src/__tests__/canvas-input-boundary.test.ts +65 -0
- package/src/__tests__/canvas-input-owner.test-d.ts +39 -0
- package/src/__tests__/composite-backend.test.ts +299 -0
- package/src/__tests__/controller-platform-owner.test-d.ts +46 -0
- package/src/__tests__/input-snapshot.test-d.ts +78 -0
- package/src/__tests__/input-snapshot.test.ts +96 -0
- package/src/__tests__/swipe-direction-owner.test-d.ts +45 -0
- package/src/__tests__/ui-input-matrix.test.ts +31 -0
- package/src/__tests__/ui-ownership-edge.test.ts +33 -0
- package/src/__tests__/ui-ownership.test.ts +35 -0
- package/src/__tests__/ui-reset-idempotence.test.ts +17 -0
- package/src/__tests__/ui-reset.test.ts +25 -0
- package/src/action-state.ts +380 -0
- package/src/browser-backend.ts +884 -0
- package/src/canvas-input-boundary.ts +81 -0
- package/src/composite-backend.ts +189 -0
- package/src/controller-db-data.ts +25 -0
- package/src/controller-db.ts +218 -0
- package/src/frame-start-scan-system.ts +93 -0
- package/src/gamepad-frame.ts +259 -0
- package/src/gesture-recognizer.ts +495 -0
- package/src/index.ts +101 -0
- package/src/input-snapshot.ts +732 -0
- package/src/plugin-service.ts +31 -0
- package/src/ui-ownership.ts +61 -0
- package/src/virtual-joystick.ts +103 -0
- package/vendor/gamecontrollerdb.txt +2114 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
5
|
+
import type { CanvasInputBoundary } from '../canvas-input-boundary.js';
|
|
6
|
+
|
|
7
|
+
type CanvasInputOwner = ReturnType<CanvasInputBoundary['owner']>;
|
|
8
|
+
type ExpectedCanvasInputOwner = 'editor' | 'game';
|
|
9
|
+
|
|
10
|
+
const canvasInputBoundarySource = readFileSync(
|
|
11
|
+
new URL('../canvas-input-boundary.ts', import.meta.url),
|
|
12
|
+
'utf8',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
describe('canvas input owner', () => {
|
|
16
|
+
it('keeps the public owner vocabulary exact and bilateral', () => {
|
|
17
|
+
expectTypeOf<CanvasInputOwner>().toEqualTypeOf<ExpectedCanvasInputOwner>();
|
|
18
|
+
expectTypeOf<ExpectedCanvasInputOwner>().toEqualTypeOf<CanvasInputOwner>();
|
|
19
|
+
expectTypeOf<CanvasInputBoundary['owner']>().toEqualTypeOf<() => ExpectedCanvasInputOwner>();
|
|
20
|
+
|
|
21
|
+
const acceptsOwner = (owner: CanvasInputOwner): CanvasInputOwner => owner;
|
|
22
|
+
acceptsOwner('editor');
|
|
23
|
+
acceptsOwner('game');
|
|
24
|
+
// @ts-expect-error Unknown roles remain outside the closed owner vocabulary.
|
|
25
|
+
acceptsOwner('preview');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('derives both private consumer views from the public owner method', () => {
|
|
29
|
+
expect(canvasInputBoundarySource).toContain(
|
|
30
|
+
"type CanvasInputOwner = ReturnType<CanvasInputBoundary['owner']>;",
|
|
31
|
+
);
|
|
32
|
+
expect(canvasInputBoundarySource).toContain("let active: CanvasInputOwner = 'editor';");
|
|
33
|
+
expect(canvasInputBoundarySource).toContain(
|
|
34
|
+
'const routed = (consumer: CanvasInputOwner): InputBackend => ({',
|
|
35
|
+
);
|
|
36
|
+
expect(canvasInputBoundarySource).not.toContain("let active: 'editor' | 'game'");
|
|
37
|
+
expect(canvasInputBoundarySource).not.toContain("const routed = (consumer: 'editor' | 'game')");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// composite-backend.test.ts -- merge-semantics contract for the
|
|
2
|
+
// CompositeInputBackend decorator.
|
|
3
|
+
//
|
|
4
|
+
// WHY this backend exists: an AI (or a record/replay harness) needs to feed
|
|
5
|
+
// synthetic input into the SAME `INPUT_BACKEND_KEY` world resource a human's
|
|
6
|
+
// browser backend occupies -- WITHOUT evicting the human (PIE + human-as-final-
|
|
7
|
+
// authority: a human can take over at any instant). Replacing the resource
|
|
8
|
+
// would lock the human out; a decorator merges both sources so they coexist.
|
|
9
|
+
//
|
|
10
|
+
// The scan system calls `backend.sample()` exactly once per frame and RELIES on
|
|
11
|
+
// its side effects (up-edge / movement / wheel accumulators drain on read).
|
|
12
|
+
// So the composite must call `inner.sample()` exactly once and merge the
|
|
13
|
+
// injected state field-by-field, honoring each field's lifecycle:
|
|
14
|
+
//
|
|
15
|
+
// downKeys held across frames -> UNION(inner, injected.held)
|
|
16
|
+
// upKeys lives exactly ONE frame -> UNION, injected side drained here
|
|
17
|
+
// buttons held tuple -> OR per slot
|
|
18
|
+
// movementX/Y, wheelDelta accumulators -> SUM, injected side drained here
|
|
19
|
+
// focused gates up-edge suppression -> inner.focused || injectedActive
|
|
20
|
+
// pointerLocked -> inner (AI never fabricates a lock)
|
|
21
|
+
//
|
|
22
|
+
// yieldToHuman (default ON): a human-held key suppresses injected state only for
|
|
23
|
+
// that SAME key in that frame, so the AI never fights the human for that key while
|
|
24
|
+
// unrelated synthetic input can continue. This is the structural "human wins" gate.
|
|
25
|
+
|
|
26
|
+
import { describe, expect, it } from 'vitest';
|
|
27
|
+
// Import from the leaf modules, not the '../index' barrel: the barrel re-exports
|
|
28
|
+
// frame-start-scan-system, which value-imports @forgeax/engine-ecs and would drag
|
|
29
|
+
// an (unbuilt-dist) runtime dependency into this pure unit test. composite-backend
|
|
30
|
+
// and input-snapshot have no engine-ecs dependency.
|
|
31
|
+
import { makeCompositeBackend } from '../composite-backend';
|
|
32
|
+
import type { InputBackend, InputBackendSample } from '../input-snapshot';
|
|
33
|
+
|
|
34
|
+
/** Minimal programmable stand-in for the human/browser backend. */
|
|
35
|
+
function fakeInner(init?: Partial<InputBackendSample>): InputBackend & {
|
|
36
|
+
set: (patch: Partial<InputBackendSample>) => void;
|
|
37
|
+
sampleCalls: () => number;
|
|
38
|
+
} {
|
|
39
|
+
let calls = 0;
|
|
40
|
+
let state: InputBackendSample = {
|
|
41
|
+
downKeys: new Set<string>(),
|
|
42
|
+
upKeys: new Set<string>(),
|
|
43
|
+
buttons: [false, false, false],
|
|
44
|
+
movementX: 0,
|
|
45
|
+
movementY: 0,
|
|
46
|
+
wheelDelta: 0,
|
|
47
|
+
focused: true,
|
|
48
|
+
pointerLocked: false,
|
|
49
|
+
...init,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
sample(): InputBackendSample {
|
|
53
|
+
calls++;
|
|
54
|
+
const out = { ...state, downKeys: new Set(state.downKeys), upKeys: new Set(state.upKeys) };
|
|
55
|
+
// Mirror the real backend: accumulators drain on read.
|
|
56
|
+
state = { ...state, upKeys: new Set(), movementX: 0, movementY: 0, wheelDelta: 0 };
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
detach() {},
|
|
60
|
+
set(patch) {
|
|
61
|
+
state = { ...state, ...patch };
|
|
62
|
+
},
|
|
63
|
+
sampleCalls: () => calls,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('CompositeInputBackend merge semantics', () => {
|
|
68
|
+
it('calls inner.sample() exactly once per composite.sample() (respects side effects)', () => {
|
|
69
|
+
const inner = fakeInner();
|
|
70
|
+
const c = makeCompositeBackend(inner);
|
|
71
|
+
c.sample();
|
|
72
|
+
c.sample();
|
|
73
|
+
expect(inner.sampleCalls()).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('downKeys is the UNION of human-held and AI-injected keys', () => {
|
|
77
|
+
// yieldToHuman OFF so overlap coexists -- pure union semantics. (The default
|
|
78
|
+
// yield behavior is covered separately in the yieldToHuman describe block.)
|
|
79
|
+
const inner = fakeInner({ downKeys: new Set(['d']) });
|
|
80
|
+
const c = makeCompositeBackend(inner, { yieldToHuman: false });
|
|
81
|
+
c.press('w');
|
|
82
|
+
const s = c.sample();
|
|
83
|
+
expect(s.downKeys.has('w')).toBe(true); // AI
|
|
84
|
+
expect(s.downKeys.has('d')).toBe(true); // human
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('injected key stays held across frames until released', () => {
|
|
88
|
+
const inner = fakeInner();
|
|
89
|
+
const c = makeCompositeBackend(inner);
|
|
90
|
+
c.press('w');
|
|
91
|
+
expect(c.sample().downKeys.has('w')).toBe(true);
|
|
92
|
+
expect(c.sample().downKeys.has('w')).toBe(true); // still held next frame
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('release produces an up-edge that lives exactly ONE frame', () => {
|
|
96
|
+
const inner = fakeInner();
|
|
97
|
+
const c = makeCompositeBackend(inner);
|
|
98
|
+
c.press('w');
|
|
99
|
+
c.sample(); // held
|
|
100
|
+
c.release('w');
|
|
101
|
+
const s1 = c.sample();
|
|
102
|
+
expect(s1.upKeys.has('w')).toBe(true); // edge appears once
|
|
103
|
+
expect(s1.downKeys.has('w')).toBe(false); // no longer held
|
|
104
|
+
const s2 = c.sample();
|
|
105
|
+
expect(s2.upKeys.has('w')).toBe(false); // and is gone the next frame
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('merges human and AI up-edges in the same frame', () => {
|
|
109
|
+
const inner = fakeInner({ upKeys: new Set(['d']) });
|
|
110
|
+
const c = makeCompositeBackend(inner);
|
|
111
|
+
c.press('w');
|
|
112
|
+
c.sample();
|
|
113
|
+
c.release('w');
|
|
114
|
+
inner.set({ upKeys: new Set(['d']) });
|
|
115
|
+
const s = c.sample();
|
|
116
|
+
expect(s.upKeys.has('w')).toBe(true); // AI
|
|
117
|
+
expect(s.upKeys.has('d')).toBe(true); // human
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('buttons are OR-merged per slot (AI never clears a human button)', () => {
|
|
121
|
+
const inner = fakeInner({ buttons: [false, true, false] });
|
|
122
|
+
const c = makeCompositeBackend(inner);
|
|
123
|
+
c.setButton(0, true);
|
|
124
|
+
const s = c.sample();
|
|
125
|
+
expect(s.buttons).toEqual([true, true, false]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('movement + wheel accumulators are SUMMED and the injected side drains on read', () => {
|
|
129
|
+
const inner = fakeInner({ movementX: 3, movementY: -2, wheelDelta: 1 });
|
|
130
|
+
const c = makeCompositeBackend(inner);
|
|
131
|
+
c.addMovement(10, 20);
|
|
132
|
+
c.addWheel(2);
|
|
133
|
+
const s1 = c.sample();
|
|
134
|
+
expect(s1.movementX).toBe(13);
|
|
135
|
+
expect(s1.movementY).toBe(18);
|
|
136
|
+
expect(s1.wheelDelta).toBe(3);
|
|
137
|
+
// Injected accumulator must reset (inner already drains itself).
|
|
138
|
+
const s2 = c.sample();
|
|
139
|
+
expect(s2.movementX).toBe(0);
|
|
140
|
+
expect(s2.movementY).toBe(0);
|
|
141
|
+
expect(s2.wheelDelta).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('focused is forced true while AI injection is active (so scan does not suppress AI up-edges)', () => {
|
|
145
|
+
const inner = fakeInner({ focused: false }); // headless / backgrounded tab
|
|
146
|
+
const c = makeCompositeBackend(inner);
|
|
147
|
+
c.press('w');
|
|
148
|
+
expect(c.sample().focused).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('focused falls back to inner when there is no active injection', () => {
|
|
152
|
+
const inner = fakeInner({ focused: false });
|
|
153
|
+
const c = makeCompositeBackend(inner);
|
|
154
|
+
expect(c.sample().focused).toBe(false);
|
|
155
|
+
inner.set({ focused: true });
|
|
156
|
+
expect(c.sample().focused).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('pointerLocked passes through from inner (AI cannot fabricate a lock)', () => {
|
|
160
|
+
const inner = fakeInner({ pointerLocked: true });
|
|
161
|
+
const c = makeCompositeBackend(inner);
|
|
162
|
+
c.press('w');
|
|
163
|
+
expect(c.sample().pointerLocked).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('yieldToHuman gate (human-as-final-authority) — PER-KEY', () => {
|
|
167
|
+
it('the human wins only the SAME key; unrelated AI keys coexist', () => {
|
|
168
|
+
const inner = fakeInner();
|
|
169
|
+
const c = makeCompositeBackend(inner); // yieldToHuman defaults ON
|
|
170
|
+
c.press('w');
|
|
171
|
+
expect(c.sample().downKeys.has('w')).toBe(true); // AI drives while human idle
|
|
172
|
+
|
|
173
|
+
// Human presses a DIFFERENT key (d): AI's w survives, human's d is added —
|
|
174
|
+
// they coexist. This is the key difference from a global-yield gate.
|
|
175
|
+
inner.set({ downKeys: new Set(['d']) });
|
|
176
|
+
const s = c.sample();
|
|
177
|
+
expect(s.downKeys.has('d')).toBe(true); // human's key
|
|
178
|
+
expect(s.downKeys.has('w')).toBe(true); // AI's DIFFERENT key still lives
|
|
179
|
+
|
|
180
|
+
inner.set({ downKeys: new Set() });
|
|
181
|
+
expect(c.sample().downKeys.has('w')).toBe(true); // AI still held
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('a human press on the SAME key the AI holds suppresses the AI copy (no double-count)', () => {
|
|
185
|
+
const inner = fakeInner();
|
|
186
|
+
const c = makeCompositeBackend(inner);
|
|
187
|
+
c.press('w');
|
|
188
|
+
// Human grabs the SAME key. downKeys still has 'w' exactly once (Set), but the
|
|
189
|
+
// AI copy is suppressed so releasing the AI key does not linger under the human.
|
|
190
|
+
inner.set({ downKeys: new Set(['w']) });
|
|
191
|
+
expect(c.sample().downKeys.has('w')).toBe(true); // present via the human
|
|
192
|
+
|
|
193
|
+
// Human lets go while AI still holds w -> AI resumes ownership of w.
|
|
194
|
+
inner.set({ downKeys: new Set() });
|
|
195
|
+
expect(c.sample().downKeys.has('w')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('suppresses an injected up-edge while the human still holds that same key', () => {
|
|
199
|
+
const inner = fakeInner();
|
|
200
|
+
const c = makeCompositeBackend(inner);
|
|
201
|
+
c.press('w');
|
|
202
|
+
c.sample();
|
|
203
|
+
|
|
204
|
+
// The human takes w, then the AI releases its now-yielded copy. The composite
|
|
205
|
+
// must not tell the scan system that w went up while the human still holds it.
|
|
206
|
+
inner.set({ downKeys: new Set(['w']) });
|
|
207
|
+
c.release('w');
|
|
208
|
+
const s = c.sample();
|
|
209
|
+
expect(s.downKeys.has('w')).toBe(true);
|
|
210
|
+
expect(s.upKeys.has('w')).toBe(false);
|
|
211
|
+
|
|
212
|
+
// The injected edge was drained with the sample, so releasing the human key
|
|
213
|
+
// later is represented only by the human backend's own edge.
|
|
214
|
+
inner.set({ downKeys: new Set(), upKeys: new Set(['w']) });
|
|
215
|
+
const afterHumanRelease = c.sample();
|
|
216
|
+
expect(afterHumanRelease.downKeys.has('w')).toBe(false);
|
|
217
|
+
expect(afterHumanRelease.upKeys.has('w')).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('with yieldToHuman disabled, AI and human coexist even on the SAME key', () => {
|
|
221
|
+
const inner = fakeInner({ downKeys: new Set(['w']) });
|
|
222
|
+
const c = makeCompositeBackend(inner, { yieldToHuman: false });
|
|
223
|
+
c.press('w');
|
|
224
|
+
const s = c.sample();
|
|
225
|
+
expect(s.downKeys.has('w')).toBe(true); // union collapses to one anyway
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('setYieldToHuman toggles the SAME-key gate at runtime', () => {
|
|
229
|
+
const inner = fakeInner({ downKeys: new Set(['w']) }); // human holds w
|
|
230
|
+
const c = makeCompositeBackend(inner);
|
|
231
|
+
c.press('w'); // AI also holds w
|
|
232
|
+
// With yield on, the AI copy of w is suppressed; the human still supplies w,
|
|
233
|
+
// so to observe the gate we track a SECOND AI-only key and the up-edge behavior.
|
|
234
|
+
c.press('a'); // AI-only key (human not pressing it)
|
|
235
|
+
let s = c.sample();
|
|
236
|
+
expect(s.downKeys.has('a')).toBe(true); // AI-only key always lives
|
|
237
|
+
expect(s.downKeys.has('w')).toBe(true); // supplied by the human
|
|
238
|
+
// Disable yield: nothing changes for a union of held keys (w still one entry).
|
|
239
|
+
c.setYieldToHuman(false);
|
|
240
|
+
s = c.sample();
|
|
241
|
+
expect(s.downKeys.has('w')).toBe(true);
|
|
242
|
+
expect(s.downKeys.has('a')).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('clearInjected drops all AI state (held keys become up-edges once)', () => {
|
|
247
|
+
const inner = fakeInner();
|
|
248
|
+
const c = makeCompositeBackend(inner);
|
|
249
|
+
c.press('w');
|
|
250
|
+
c.press('a');
|
|
251
|
+
c.sample();
|
|
252
|
+
c.clearInjected();
|
|
253
|
+
const s = c.sample();
|
|
254
|
+
expect(s.downKeys.has('w')).toBe(false);
|
|
255
|
+
expect(s.downKeys.has('a')).toBe(false);
|
|
256
|
+
expect(s.upKeys.has('w')).toBe(true); // clean release edge
|
|
257
|
+
expect(s.upKeys.has('a')).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('forwards setPointerLockAllowed and detach to inner', () => {
|
|
261
|
+
let allowed: boolean | undefined;
|
|
262
|
+
let detached = false;
|
|
263
|
+
const inner: InputBackend = {
|
|
264
|
+
sample: () => ({
|
|
265
|
+
downKeys: new Set(),
|
|
266
|
+
upKeys: new Set(),
|
|
267
|
+
buttons: [false, false, false],
|
|
268
|
+
movementX: 0,
|
|
269
|
+
movementY: 0,
|
|
270
|
+
wheelDelta: 0,
|
|
271
|
+
focused: true,
|
|
272
|
+
pointerLocked: false,
|
|
273
|
+
}),
|
|
274
|
+
setPointerLockAllowed: (a) => {
|
|
275
|
+
allowed = a;
|
|
276
|
+
},
|
|
277
|
+
detach: () => {
|
|
278
|
+
detached = true;
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const c = makeCompositeBackend(inner);
|
|
282
|
+
c.setPointerLockAllowed?.(false);
|
|
283
|
+
expect(allowed).toBe(false);
|
|
284
|
+
c.detach();
|
|
285
|
+
expect(detached).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('preserves inner optional fields (pointerEvents/pointers) untouched', () => {
|
|
289
|
+
const inner = fakeInner();
|
|
290
|
+
inner.set({
|
|
291
|
+
pointerEvents: [
|
|
292
|
+
{ pointerId: 1, phase: 'down', x: 5, y: 6, pressure: 1, pointerType: 'mouse' },
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
const c = makeCompositeBackend(inner);
|
|
296
|
+
const s = c.sample();
|
|
297
|
+
expect(s.pointerEvents?.[0]?.phase).toBe('down');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
5
|
+
import type { ControllerPlatform } from '../controller-db.js';
|
|
6
|
+
import { platformFromUserAgent } from '../controller-db.js';
|
|
7
|
+
|
|
8
|
+
type ExpectedControllerPlatform = 'Windows' | 'Mac OS X' | 'Linux' | 'Android' | 'iOS';
|
|
9
|
+
|
|
10
|
+
const controllerDbSource = readFileSync(new URL('../controller-db.ts', import.meta.url), 'utf8');
|
|
11
|
+
|
|
12
|
+
describe('controller platform owner', () => {
|
|
13
|
+
it('keeps the exact public membership and producer declaration', () => {
|
|
14
|
+
expectTypeOf<ControllerPlatform>().toEqualTypeOf<ExpectedControllerPlatform>();
|
|
15
|
+
expectTypeOf<ExpectedControllerPlatform>().toEqualTypeOf<ControllerPlatform>();
|
|
16
|
+
expectTypeOf(platformFromUserAgent).toEqualTypeOf<
|
|
17
|
+
(ua: string) => ControllerPlatform | undefined
|
|
18
|
+
>();
|
|
19
|
+
|
|
20
|
+
const acceptsPlatform = (platform: ControllerPlatform): ControllerPlatform => platform;
|
|
21
|
+
acceptsPlatform('Windows');
|
|
22
|
+
acceptsPlatform('Mac OS X');
|
|
23
|
+
acceptsPlatform('Linux');
|
|
24
|
+
acceptsPlatform('Android');
|
|
25
|
+
acceptsPlatform('iOS');
|
|
26
|
+
// @ts-expect-error unknown platform labels remain outside the closed union.
|
|
27
|
+
acceptsPlatform('platform-not-real');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('derives the public union and user-agent output from one private tuple', () => {
|
|
31
|
+
expect(controllerDbSource).toContain(
|
|
32
|
+
"const CONTROLLER_PLATFORMS = ['Windows', 'Mac OS X', 'Linux', 'Android', 'iOS'] as const;",
|
|
33
|
+
);
|
|
34
|
+
expect(controllerDbSource).toContain(
|
|
35
|
+
'export type ControllerPlatform = (typeof CONTROLLER_PLATFORMS)[number];',
|
|
36
|
+
);
|
|
37
|
+
expect(controllerDbSource).toContain('return CONTROLLER_PLATFORMS[4];');
|
|
38
|
+
expect(controllerDbSource).toContain('return CONTROLLER_PLATFORMS[3];');
|
|
39
|
+
expect(controllerDbSource).toContain('return CONTROLLER_PLATFORMS[0];');
|
|
40
|
+
expect(controllerDbSource).toContain('return CONTROLLER_PLATFORMS[1];');
|
|
41
|
+
expect(controllerDbSource).toContain('return CONTROLLER_PLATFORMS[2];');
|
|
42
|
+
expect(controllerDbSource).not.toContain(
|
|
43
|
+
"export type ControllerPlatform = 'Windows' | 'Mac OS X' | 'Linux' | 'Android' | 'iOS';",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// input-snapshot.test-d.ts -- compile-time assertions for AC-07.
|
|
2
|
+
//
|
|
3
|
+
// Anchors:
|
|
4
|
+
// - mouse.button(i) accepts the literal union 0 | 1 | 2 (W3C MouseEvent.button:
|
|
5
|
+
// 0=primary, 1=auxiliary, 2=secondary; plan-strategy section 2.10 D-5).
|
|
6
|
+
// - mouse.button(3) is a TS error (literal-narrowing rejects out-of-range).
|
|
7
|
+
// - keyboard.down/justPressed/up plus physical-code readers return boolean
|
|
8
|
+
// (not unknown / not Result).
|
|
9
|
+
//
|
|
10
|
+
// charter awareness:
|
|
11
|
+
// - F2 minimal surface: keyboard read points are locked at compile time
|
|
12
|
+
// - P3 explicit failure: invalid button index is a compile-time error,
|
|
13
|
+
// not a silent runtime fallback (no string parsing, no number coercion)
|
|
14
|
+
|
|
15
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
16
|
+
import { createInputSnapshot, type InputSnapshot } from '../index';
|
|
17
|
+
|
|
18
|
+
describe('InputSnapshot type surface (AC-07)', () => {
|
|
19
|
+
it('keyboard.down accepts string and returns boolean', () => {
|
|
20
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
21
|
+
expectTypeOf(snap.keyboard.down).parameter(0).toEqualTypeOf<string>();
|
|
22
|
+
expectTypeOf(snap.keyboard.down('w')).toEqualTypeOf<boolean>();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('keyboard.up accepts string and returns boolean', () => {
|
|
26
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
27
|
+
expectTypeOf(snap.keyboard.up).parameter(0).toEqualTypeOf<string>();
|
|
28
|
+
expectTypeOf(snap.keyboard.up('w')).toEqualTypeOf<boolean>();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('keyboard.justPressed accepts string and returns boolean', () => {
|
|
32
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
33
|
+
expectTypeOf(snap.keyboard.justPressed).parameter(0).toEqualTypeOf<string>();
|
|
34
|
+
expectTypeOf(snap.keyboard.justPressed('w')).toEqualTypeOf<boolean>();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keyboard code readers accept string and return boolean', () => {
|
|
38
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
39
|
+
expectTypeOf(snap.keyboard.downCode).parameter(0).toEqualTypeOf<string>();
|
|
40
|
+
expectTypeOf(snap.keyboard.justPressedCode).parameter(0).toEqualTypeOf<string>();
|
|
41
|
+
expectTypeOf(snap.keyboard.upCode).parameter(0).toEqualTypeOf<string>();
|
|
42
|
+
expectTypeOf(snap.keyboard.downCode('KeyA')).toEqualTypeOf<boolean>();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('mouse.movementDelta has shape { x: number; y: number }', () => {
|
|
46
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
47
|
+
expectTypeOf(snap.mouse.movementDelta).toEqualTypeOf<{
|
|
48
|
+
readonly x: number;
|
|
49
|
+
readonly y: number;
|
|
50
|
+
}>();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('mouse.position has shape { x: number; y: number }', () => {
|
|
54
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
55
|
+
expectTypeOf(snap.mouse.position).toEqualTypeOf<
|
|
56
|
+
{ readonly x: number; readonly y: number } | undefined
|
|
57
|
+
>();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('mouse.pointerLocked returns boolean (required, alongside movementDelta)', () => {
|
|
61
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
62
|
+
expectTypeOf(snap.mouse.pointerLocked).toEqualTypeOf<boolean>();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('mouse button edge readers accept the literal union and return boolean', () => {
|
|
66
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
67
|
+
expectTypeOf(snap.mouse.justPressed(0)).toEqualTypeOf<boolean>();
|
|
68
|
+
expectTypeOf(snap.mouse.justReleased(0)).toEqualTypeOf<boolean>();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('mouse.button(3) is a TS compile error (literal-narrowing rejects out-of-range)', () => {
|
|
72
|
+
const snap: InputSnapshot = createInputSnapshot();
|
|
73
|
+
// @ts-expect-error -- 3 is not assignable to 0 | 1 | 2 (charter P3 explicit failure)
|
|
74
|
+
snap.mouse.button(3);
|
|
75
|
+
// @ts-expect-error -- arbitrary number is not assignable either
|
|
76
|
+
snap.mouse.button(42 as number);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createEmptyInputBackendSample,
|
|
5
|
+
type InputBackendSample,
|
|
6
|
+
snapshotFromSample,
|
|
7
|
+
} from '../input-snapshot';
|
|
8
|
+
|
|
9
|
+
function sample(
|
|
10
|
+
downKeys: string[],
|
|
11
|
+
upKeys: string[] = [],
|
|
12
|
+
downCodes: string[] = [],
|
|
13
|
+
upCodes: string[] = [],
|
|
14
|
+
): InputBackendSample {
|
|
15
|
+
return {
|
|
16
|
+
downKeys: new Set(downKeys),
|
|
17
|
+
upKeys: new Set(upKeys),
|
|
18
|
+
downCodes: new Set(downCodes),
|
|
19
|
+
upCodes: new Set(upCodes),
|
|
20
|
+
buttons: [false, false, false],
|
|
21
|
+
movementX: 0,
|
|
22
|
+
movementY: 0,
|
|
23
|
+
wheelDelta: 0,
|
|
24
|
+
focused: true,
|
|
25
|
+
pointerLocked: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('keyboard snapshot edges', () => {
|
|
30
|
+
it('derives justPressed from the previous frozen snapshot', () => {
|
|
31
|
+
const first = snapshotFromSample(sample(['a']));
|
|
32
|
+
const held = snapshotFromSample(sample(['a']), undefined, undefined, first);
|
|
33
|
+
const released = snapshotFromSample(sample([], ['a']), undefined, undefined, held);
|
|
34
|
+
|
|
35
|
+
expect(first.keyboard.down('a')).toBe(true);
|
|
36
|
+
expect(first.keyboard.justPressed('a')).toBe(true);
|
|
37
|
+
expect(held.keyboard.justPressed('a')).toBe(false);
|
|
38
|
+
expect(released.keyboard.down('a')).toBe(false);
|
|
39
|
+
expect(released.keyboard.up('a')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps code-like and logical key strings as independent read points', () => {
|
|
43
|
+
const snap = snapshotFromSample(sample(['?'], [], ['KeyA']));
|
|
44
|
+
|
|
45
|
+
expect(snap.keyboard.down('?')).toBe(true);
|
|
46
|
+
expect(snap.keyboard.justPressed('?')).toBe(true);
|
|
47
|
+
expect(snap.keyboard.downCode('KeyA')).toBe(true);
|
|
48
|
+
expect(snap.keyboard.justPressedCode('KeyA')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('empty backend sample ownership', () => {
|
|
53
|
+
it('keeps the neutral sample owner out of the public root', async () => {
|
|
54
|
+
const root = (await import('../index')) as Record<string, unknown>;
|
|
55
|
+
const empty = createEmptyInputBackendSample();
|
|
56
|
+
|
|
57
|
+
expect(root).not.toHaveProperty('createEmptyInputBackendSample');
|
|
58
|
+
expect(empty.downKeys.size).toBe(0);
|
|
59
|
+
expect(empty.upKeys.size).toBe(0);
|
|
60
|
+
expect(empty.buttons).toEqual([false, false, false]);
|
|
61
|
+
expect(empty.movementX).toBe(0);
|
|
62
|
+
expect(empty.movementY).toBe(0);
|
|
63
|
+
expect(empty.wheelDelta).toBe(0);
|
|
64
|
+
expect(empty.focused).toBe(true);
|
|
65
|
+
expect(empty.pointerLocked).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('mouse snapshot edges', () => {
|
|
70
|
+
it('derives primary-button held, press, and release edges from snapshots', () => {
|
|
71
|
+
const first = snapshotFromSample({
|
|
72
|
+
...sample([]),
|
|
73
|
+
buttons: [true, false, false],
|
|
74
|
+
});
|
|
75
|
+
const held = snapshotFromSample(
|
|
76
|
+
{ ...sample([]), buttons: [true, false, false] },
|
|
77
|
+
undefined,
|
|
78
|
+
undefined,
|
|
79
|
+
first,
|
|
80
|
+
);
|
|
81
|
+
const released = snapshotFromSample(
|
|
82
|
+
{ ...sample([]), buttons: [false, false, false] },
|
|
83
|
+
undefined,
|
|
84
|
+
undefined,
|
|
85
|
+
held,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(first.mouse.button(0)).toBe(true);
|
|
89
|
+
expect(first.mouse.justPressed(0)).toBe(true);
|
|
90
|
+
expect(first.mouse.justReleased(0)).toBe(false);
|
|
91
|
+
expect(held.mouse.justPressed(0)).toBe(false);
|
|
92
|
+
expect(held.mouse.justReleased(0)).toBe(false);
|
|
93
|
+
expect(released.mouse.button(0)).toBe(false);
|
|
94
|
+
expect(released.mouse.justReleased(0)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
5
|
+
import type { SwipeDirection } from '../gesture-recognizer.js';
|
|
6
|
+
|
|
7
|
+
type ExpectedSwipeDirection = 'left' | 'right' | 'up' | 'down';
|
|
8
|
+
|
|
9
|
+
const gestureRecognizerSource = readFileSync(
|
|
10
|
+
new URL('../gesture-recognizer.ts', import.meta.url),
|
|
11
|
+
'utf8',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
describe('swipe direction owner', () => {
|
|
15
|
+
it('keeps the exact public membership and producer declaration', () => {
|
|
16
|
+
expectTypeOf<SwipeDirection>().toEqualTypeOf<ExpectedSwipeDirection>();
|
|
17
|
+
expectTypeOf<ExpectedSwipeDirection>().toEqualTypeOf<SwipeDirection>();
|
|
18
|
+
|
|
19
|
+
const acceptsDirection = (direction: SwipeDirection): SwipeDirection => direction;
|
|
20
|
+
acceptsDirection('left');
|
|
21
|
+
acceptsDirection('right');
|
|
22
|
+
acceptsDirection('up');
|
|
23
|
+
acceptsDirection('down');
|
|
24
|
+
// @ts-expect-error unknown direction labels remain outside the closed union.
|
|
25
|
+
acceptsDirection('direction-not-real');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('derives the public union and dominant-axis output from one private tuple', () => {
|
|
29
|
+
expect(gestureRecognizerSource).toContain(
|
|
30
|
+
"const SWIPE_DIRECTIONS = ['left', 'right', 'up', 'down'] as const;",
|
|
31
|
+
);
|
|
32
|
+
expect(gestureRecognizerSource).toContain(
|
|
33
|
+
'export type SwipeDirection = (typeof SWIPE_DIRECTIONS)[number];',
|
|
34
|
+
);
|
|
35
|
+
expect(gestureRecognizerSource).toContain(
|
|
36
|
+
'if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? SWIPE_DIRECTIONS[1] : SWIPE_DIRECTIONS[0];',
|
|
37
|
+
);
|
|
38
|
+
expect(gestureRecognizerSource).toContain(
|
|
39
|
+
'return dy >= 0 ? SWIPE_DIRECTIONS[3] : SWIPE_DIRECTIONS[2];',
|
|
40
|
+
);
|
|
41
|
+
expect(gestureRecognizerSource).not.toContain(
|
|
42
|
+
"export type SwipeDirection = 'left' | 'right' | 'up' | 'down';",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { isUiOwnedEvent } from '../ui-ownership';
|
|
6
|
+
|
|
7
|
+
describe('UI input ownership matrix', () => {
|
|
8
|
+
it.each(['input', 'select', 'range', 'checkbox', 'contenteditable'])('%s is UI-owned', (kind) => {
|
|
9
|
+
const root = document.createElement('div');
|
|
10
|
+
const element =
|
|
11
|
+
kind === 'contenteditable'
|
|
12
|
+
? document.createElement('div')
|
|
13
|
+
: kind === 'select'
|
|
14
|
+
? document.createElement('select')
|
|
15
|
+
: document.createElement('input');
|
|
16
|
+
if (kind === 'contenteditable') element.contentEditable = 'true';
|
|
17
|
+
if (kind === 'range' || kind === 'checkbox') element.setAttribute('type', kind);
|
|
18
|
+
root.append(element);
|
|
19
|
+
const event = new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'a' });
|
|
20
|
+
element.dispatchEvent(event);
|
|
21
|
+
expect(isUiOwnedEvent(event, root)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('leaves a canvas gameplay event outside the UI root', () => {
|
|
25
|
+
const root = document.createElement('div');
|
|
26
|
+
const canvas = document.createElement('canvas');
|
|
27
|
+
const event = new KeyboardEvent('keydown', { bubbles: true, key: 'a' });
|
|
28
|
+
canvas.dispatchEvent(event);
|
|
29
|
+
expect(isUiOwnedEvent(event, root)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { resolveUiOwnership } from '../ui-ownership';
|
|
6
|
+
|
|
7
|
+
describe('UI ownership fallback paths', () => {
|
|
8
|
+
it('reports target fallback when composedPath is unavailable', () => {
|
|
9
|
+
const root = document.createElement('div');
|
|
10
|
+
const child = document.createElement('input');
|
|
11
|
+
root.append(child);
|
|
12
|
+
const event = new Event('keydown');
|
|
13
|
+
Object.defineProperty(event, 'target', { configurable: true, value: child });
|
|
14
|
+
Object.defineProperty(event, 'composedPath', { configurable: true, value: undefined });
|
|
15
|
+
|
|
16
|
+
expect(resolveUiOwnership(event, root)).toEqual({
|
|
17
|
+
owned: true,
|
|
18
|
+
source: 'target-fallback',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns an explicit unknown result for targetless events', () => {
|
|
23
|
+
const root = document.createElement('div');
|
|
24
|
+
const event = new Event('keydown');
|
|
25
|
+
Object.defineProperty(event, 'target', { configurable: true, value: null });
|
|
26
|
+
Object.defineProperty(event, 'composedPath', { configurable: true, value: undefined });
|
|
27
|
+
|
|
28
|
+
expect(resolveUiOwnership(event, root)).toEqual({
|
|
29
|
+
owned: false,
|
|
30
|
+
source: 'target-fallback',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|