@mpgd/target-config 0.19.0 → 0.21.0
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/README.md +45 -0
- package/adaptive-shell.css +46 -0
- package/dist/adaptive-shell/index.d.ts +2 -0
- package/dist/adaptive-shell/index.js +2 -0
- package/dist/adaptive-shell/policy.d.ts +60 -0
- package/dist/adaptive-shell/policy.js +86 -0
- package/dist/adaptive-shell/shell.d.ts +71 -0
- package/dist/adaptive-shell/shell.js +359 -0
- package/dist/asset-packs.d.ts +33 -0
- package/dist/asset-packs.js +122 -0
- package/dist/effective.d.ts +3 -0
- package/dist/effective.js +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +209 -139
- package/package.json +22 -6
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { measureTargetViewport, readTargetViewportSafeAreaInsets, resolveTargetViewportSnapshot, waitForTargetViewportMeasurement, } from '../viewport.js';
|
|
2
|
+
import { assertAdaptiveShellPolicy, assertAdaptiveShellRailSlots, resolveAdaptiveShellComposition, resolveAdaptiveShellRailBounds, resolveAdaptiveShellRailSlot, } from './policy.js';
|
|
3
|
+
/**
|
|
4
|
+
* DOM attributes the shell owns. `base.css` selects the marker attributes; game stylesheets may
|
|
5
|
+
* select the state attributes. Element ids and class names stay game-owned.
|
|
6
|
+
*/
|
|
7
|
+
export const adaptiveShellAttributes = {
|
|
8
|
+
/** Marker on the outer full-viewport shell element. */
|
|
9
|
+
shell: 'data-game-shell',
|
|
10
|
+
/** Marker on the game mount (the stage). */
|
|
11
|
+
stage: 'data-game-shell-stage',
|
|
12
|
+
/** `left` or `right` on each rail element. */
|
|
13
|
+
rail: 'data-game-shell-rail',
|
|
14
|
+
/** Shell: `side-rails`, `bottom-controls` or `compact-portrait`. */
|
|
15
|
+
compositionMode: 'data-composition-mode',
|
|
16
|
+
/** Shell: where primary controls belong for the current composition. */
|
|
17
|
+
primaryControls: 'data-primary-controls',
|
|
18
|
+
/** Stage: the same composition mode, for selectors scoped under the game mount. */
|
|
19
|
+
layoutMode: 'data-layout-mode',
|
|
20
|
+
/** Stage: `portrait` or `landscape` from the measured viewport layout. */
|
|
21
|
+
viewportOrientation: 'data-viewport-orientation',
|
|
22
|
+
};
|
|
23
|
+
export const defaultAdaptiveShellId = 'game-shell';
|
|
24
|
+
const shellOwners = new WeakMap();
|
|
25
|
+
const documentSlotOwners = new WeakMap();
|
|
26
|
+
/**
|
|
27
|
+
* Root custom-property prefixes the shell reads back (`--mpgd-safe-area-top`, ...). A rail slot
|
|
28
|
+
* using one would shadow the safe-area tokens and skew every composition.
|
|
29
|
+
*/
|
|
30
|
+
export const adaptiveShellReservedVariablePrefixes = ['--mpgd-safe-area'];
|
|
31
|
+
/** Wait for host layout instead of booting the engine with a zero-sized viewport. */
|
|
32
|
+
export function waitForAdaptiveShellViewport(container, options = {}) {
|
|
33
|
+
const view = requireWindow(container);
|
|
34
|
+
const doc = container.ownerDocument;
|
|
35
|
+
return waitForTargetViewportMeasurement({
|
|
36
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
37
|
+
measure: () => measureContainer(container),
|
|
38
|
+
subscribe: (refresh) => {
|
|
39
|
+
const observer = new view.ResizeObserver(refresh);
|
|
40
|
+
observer.observe(container);
|
|
41
|
+
view.addEventListener('resize', refresh);
|
|
42
|
+
view.visualViewport?.addEventListener('resize', refresh);
|
|
43
|
+
view.visualViewport?.addEventListener('scroll', refresh);
|
|
44
|
+
doc.addEventListener('visibilitychange', refresh);
|
|
45
|
+
return () => {
|
|
46
|
+
observer.disconnect();
|
|
47
|
+
view.removeEventListener('resize', refresh);
|
|
48
|
+
view.visualViewport?.removeEventListener('resize', refresh);
|
|
49
|
+
view.visualViewport?.removeEventListener('scroll', refresh);
|
|
50
|
+
doc.removeEventListener('visibilitychange', refresh);
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Mount the full-viewport shell around `gameRoot` and keep stage/rail bounds in sync.
|
|
57
|
+
* An existing ancestor with the shell id is reused when it already contains both rails.
|
|
58
|
+
*/
|
|
59
|
+
export function mountAdaptiveGameShell(input) {
|
|
60
|
+
const view = requireWindow(input.gameRoot);
|
|
61
|
+
const ownedInput = { ...input, policy: structuredClone(input.policy) };
|
|
62
|
+
const providedRailSlots = input.railSlots ?? [];
|
|
63
|
+
assertAdaptiveShellPolicy(ownedInput.policy);
|
|
64
|
+
assertAdaptiveShellRailSlots(providedRailSlots, {
|
|
65
|
+
attributes: Object.values(adaptiveShellAttributes),
|
|
66
|
+
variablePrefixes: adaptiveShellReservedVariablePrefixes,
|
|
67
|
+
});
|
|
68
|
+
const railSlots = providedRailSlots.map((slot) => Object.freeze({ ...slot }));
|
|
69
|
+
const doc = input.gameRoot.ownerDocument;
|
|
70
|
+
const previousOwner = shellOwners.get(input.gameRoot);
|
|
71
|
+
const slotOwners = documentSlotOwners.get(doc) ?? new Map();
|
|
72
|
+
for (const slot of railSlots) {
|
|
73
|
+
const owner = slotOwners.get(slot.cssVariablePrefix);
|
|
74
|
+
if (owner !== undefined && owner !== previousOwner) {
|
|
75
|
+
throw new Error(`Rail slot cssVariablePrefix is already in use in this document: ${slot.cssVariablePrefix}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Measure before restructuring the DOM, so an unmeasurable host fails without a half-built shell.
|
|
79
|
+
// Prefer the visible viewport for this fixed full-window shell.
|
|
80
|
+
const initialMeasurement = input.initialMeasurement ?? measureContainer(input.gameRoot);
|
|
81
|
+
if (initialMeasurement === null) {
|
|
82
|
+
throw new Error('Wait for a measurable viewport before mounting the game shell.');
|
|
83
|
+
}
|
|
84
|
+
// Validate the viewport/orientation before restructuring an authored mount.
|
|
85
|
+
resolveAdaptiveShellComposition(resolveTargetViewportSnapshot({
|
|
86
|
+
...initialMeasurement,
|
|
87
|
+
runtime: input.runtime,
|
|
88
|
+
orientationPolicy: ownedInput.policy.orientationPolicy ?? { mode: 'responsive' },
|
|
89
|
+
}), ownedInput.policy);
|
|
90
|
+
previousOwner?.();
|
|
91
|
+
const elements = ensureShellElements(input.gameRoot, input.elements ?? {});
|
|
92
|
+
const listeners = new Set();
|
|
93
|
+
let animationFrame;
|
|
94
|
+
let destroyed = false;
|
|
95
|
+
let state = resolveShellState(elements.shell, ownedInput, initialMeasurement);
|
|
96
|
+
let offset = visualViewportOffset(elements.shell);
|
|
97
|
+
applyShellState(elements, state, railSlots, offset);
|
|
98
|
+
const refresh = () => {
|
|
99
|
+
animationFrame = undefined;
|
|
100
|
+
if (destroyed) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const next = resolveShellState(elements.shell, ownedInput);
|
|
104
|
+
const nextOffset = visualViewportOffset(elements.shell);
|
|
105
|
+
if (next === null || (shellStateSignature(next) === shellStateSignature(state)
|
|
106
|
+
&& nextOffset.x === offset.x && nextOffset.y === offset.y)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
state = next;
|
|
110
|
+
offset = nextOffset;
|
|
111
|
+
applyShellState(elements, state, railSlots, offset);
|
|
112
|
+
for (const listener of listeners) {
|
|
113
|
+
notifyListener(listener, state);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const scheduleRefresh = () => {
|
|
117
|
+
if (destroyed || animationFrame !== undefined) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
animationFrame = view.requestAnimationFrame(refresh);
|
|
121
|
+
};
|
|
122
|
+
const resizeObserver = new view.ResizeObserver(scheduleRefresh);
|
|
123
|
+
resizeObserver.observe(elements.shell);
|
|
124
|
+
view.addEventListener('resize', scheduleRefresh);
|
|
125
|
+
view.visualViewport?.addEventListener('resize', scheduleRefresh);
|
|
126
|
+
view.visualViewport?.addEventListener('scroll', scheduleRefresh);
|
|
127
|
+
const controller = {
|
|
128
|
+
shell: elements.shell,
|
|
129
|
+
stage: elements.gameRoot,
|
|
130
|
+
leftRail: elements.leftRail,
|
|
131
|
+
rightRail: elements.rightRail,
|
|
132
|
+
get state() {
|
|
133
|
+
return state;
|
|
134
|
+
},
|
|
135
|
+
subscribe(listener) {
|
|
136
|
+
if (destroyed) {
|
|
137
|
+
return () => undefined;
|
|
138
|
+
}
|
|
139
|
+
listeners.add(listener);
|
|
140
|
+
return () => {
|
|
141
|
+
listeners.delete(listener);
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
destroy() {
|
|
145
|
+
if (destroyed) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
destroyed = true;
|
|
149
|
+
if (shellOwners.get(input.gameRoot) === controller.destroy) {
|
|
150
|
+
shellOwners.delete(input.gameRoot);
|
|
151
|
+
}
|
|
152
|
+
listeners.clear();
|
|
153
|
+
resizeObserver.disconnect();
|
|
154
|
+
view.removeEventListener('resize', scheduleRefresh);
|
|
155
|
+
view.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
|
156
|
+
view.visualViewport?.removeEventListener('scroll', scheduleRefresh);
|
|
157
|
+
if (animationFrame !== undefined) {
|
|
158
|
+
view.cancelAnimationFrame(animationFrame);
|
|
159
|
+
}
|
|
160
|
+
for (const slot of railSlots) {
|
|
161
|
+
if (slotOwners.get(slot.cssVariablePrefix) === controller.destroy) {
|
|
162
|
+
slotOwners.delete(slot.cssVariablePrefix);
|
|
163
|
+
clearRailSlot(elements.shell, slot);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
shellOwners.set(input.gameRoot, controller.destroy);
|
|
169
|
+
documentSlotOwners.set(doc, slotOwners);
|
|
170
|
+
for (const slot of railSlots) {
|
|
171
|
+
slotOwners.set(slot.cssVariablePrefix, controller.destroy);
|
|
172
|
+
}
|
|
173
|
+
return controller;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* One failing subscriber must not skip the others for this update. Its error is rethrown from a
|
|
177
|
+
* microtask, so it still reaches the host's uncaught-error reporting.
|
|
178
|
+
*/
|
|
179
|
+
function notifyListener(listener, state) {
|
|
180
|
+
try {
|
|
181
|
+
listener(state);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
queueMicrotask(() => {
|
|
185
|
+
throw error;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function resolveShellState(shell, input, fallback) {
|
|
190
|
+
const measurement = measureContainer(shell) ?? fallback;
|
|
191
|
+
if (measurement === undefined) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const viewport = resolveTargetViewportSnapshot({
|
|
195
|
+
...measurement,
|
|
196
|
+
runtime: input.runtime,
|
|
197
|
+
orientationPolicy: input.policy.orientationPolicy ?? { mode: 'responsive' },
|
|
198
|
+
safeAreaInsets: readTargetViewportSafeAreaInsets(requireWindow(shell).getComputedStyle(shell.ownerDocument.documentElement)),
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
viewport,
|
|
202
|
+
composition: resolveAdaptiveShellComposition(viewport, input.policy),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function measureContainer(container) {
|
|
206
|
+
const view = requireWindow(container);
|
|
207
|
+
return measureTargetViewport({ visualViewport: view.visualViewport, window: view })
|
|
208
|
+
?? measureTargetViewport({ container });
|
|
209
|
+
}
|
|
210
|
+
function ensureShellElements(gameRoot, names) {
|
|
211
|
+
const shellId = names.shellId ?? defaultAdaptiveShellId;
|
|
212
|
+
const existingShell = findAncestorById(gameRoot, shellId);
|
|
213
|
+
if (existingShell !== null) {
|
|
214
|
+
const leftRail = requireRail(existingShell, 'left');
|
|
215
|
+
const rightRail = requireRail(existingShell, 'right');
|
|
216
|
+
markShellElements(existingShell, gameRoot);
|
|
217
|
+
return { shell: existingShell, gameRoot, leftRail, rightRail };
|
|
218
|
+
}
|
|
219
|
+
const parent = gameRoot.parentElement;
|
|
220
|
+
if (parent === null) {
|
|
221
|
+
const mountLabel = gameRoot.id === '' ? gameRoot.tagName.toLowerCase() : `#${gameRoot.id}`;
|
|
222
|
+
throw new Error(`Game mount ${mountLabel} must have a parent element.`);
|
|
223
|
+
}
|
|
224
|
+
const doc = gameRoot.ownerDocument;
|
|
225
|
+
const shell = doc.createElement('main');
|
|
226
|
+
const leftRail = createRail(doc, 'left', names.railClassName);
|
|
227
|
+
const rightRail = createRail(doc, 'right', names.railClassName);
|
|
228
|
+
shell.id = shellId;
|
|
229
|
+
if (names.shellClassName !== undefined) {
|
|
230
|
+
shell.className = names.shellClassName;
|
|
231
|
+
}
|
|
232
|
+
markShellElements(shell, gameRoot);
|
|
233
|
+
parent.insertBefore(shell, gameRoot);
|
|
234
|
+
shell.append(leftRail, gameRoot, rightRail);
|
|
235
|
+
return { shell, gameRoot, leftRail, rightRail };
|
|
236
|
+
}
|
|
237
|
+
function findAncestorById(element, id) {
|
|
238
|
+
for (let current = element.parentElement; current !== null; current = current.parentElement) {
|
|
239
|
+
if (current.id === id) {
|
|
240
|
+
return current;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
function markShellElements(shell, gameRoot) {
|
|
246
|
+
shell.setAttribute(adaptiveShellAttributes.shell, '');
|
|
247
|
+
gameRoot.setAttribute(adaptiveShellAttributes.stage, '');
|
|
248
|
+
}
|
|
249
|
+
function requireRail(shell, side) {
|
|
250
|
+
const rail = shell.querySelector(`[${adaptiveShellAttributes.rail}="${side}"]`);
|
|
251
|
+
if (rail === null) {
|
|
252
|
+
throw new Error(`Game shell ${side} rail is missing.`);
|
|
253
|
+
}
|
|
254
|
+
return rail;
|
|
255
|
+
}
|
|
256
|
+
function createRail(doc, side, className) {
|
|
257
|
+
const rail = doc.createElement('aside');
|
|
258
|
+
if (className !== undefined) {
|
|
259
|
+
rail.className = className;
|
|
260
|
+
}
|
|
261
|
+
rail.setAttribute(adaptiveShellAttributes.rail, side);
|
|
262
|
+
rail.setAttribute('aria-hidden', 'true');
|
|
263
|
+
return rail;
|
|
264
|
+
}
|
|
265
|
+
function applyShellState(elements, state, railSlots, offset) {
|
|
266
|
+
const { composition } = state;
|
|
267
|
+
elements.shell.style.left = `${offset.x}px`;
|
|
268
|
+
elements.shell.style.top = `${offset.y}px`;
|
|
269
|
+
elements.shell.style.right = 'auto';
|
|
270
|
+
elements.shell.style.bottom = 'auto';
|
|
271
|
+
elements.shell.style.width = `${state.viewport.layout.width}px`;
|
|
272
|
+
elements.shell.style.height = `${state.viewport.layout.height}px`;
|
|
273
|
+
elements.shell.setAttribute(adaptiveShellAttributes.compositionMode, composition.mode);
|
|
274
|
+
elements.shell.setAttribute(adaptiveShellAttributes.primaryControls, composition.primaryControls);
|
|
275
|
+
elements.gameRoot.setAttribute(adaptiveShellAttributes.layoutMode, composition.mode);
|
|
276
|
+
elements.gameRoot.setAttribute(adaptiveShellAttributes.viewportOrientation, state.viewport.layout.orientation);
|
|
277
|
+
const leftRailBounds = resolveAdaptiveShellRailBounds(composition, 'left');
|
|
278
|
+
const rightRailBounds = resolveAdaptiveShellRailBounds(composition, 'right');
|
|
279
|
+
applyBounds(elements.gameRoot, composition.gameBounds);
|
|
280
|
+
applyOptionalBounds(elements.leftRail, leftRailBounds);
|
|
281
|
+
applyOptionalBounds(elements.rightRail, rightRailBounds);
|
|
282
|
+
for (const slot of railSlots) {
|
|
283
|
+
const bounds = slot.rail === 'left' ? leftRailBounds : rightRailBounds;
|
|
284
|
+
applyRailSlot(elements.shell, slot, bounds === undefined
|
|
285
|
+
? undefined
|
|
286
|
+
: { ...bounds, x: bounds.x + offset.x, y: bounds.y + offset.y });
|
|
287
|
+
}
|
|
288
|
+
applyRailVisibility(elements.leftRail, leftRailBounds);
|
|
289
|
+
applyRailVisibility(elements.rightRail, rightRailBounds);
|
|
290
|
+
}
|
|
291
|
+
function applyRailVisibility(rail, bounds) {
|
|
292
|
+
if (bounds === undefined) {
|
|
293
|
+
rail.setAttribute('aria-hidden', 'true');
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
rail.removeAttribute('aria-hidden');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Slot variables live on the document root so body-mounted overlays (popovers, dialogs) outside
|
|
301
|
+
* the shell subtree can position themselves against the rail.
|
|
302
|
+
*/
|
|
303
|
+
function applyRailSlot(shell, slot, railBounds) {
|
|
304
|
+
const placement = resolveAdaptiveShellRailSlot(railBounds, slot);
|
|
305
|
+
if (placement === undefined) {
|
|
306
|
+
clearRailSlot(shell, slot);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const rootStyle = shell.ownerDocument.documentElement.style;
|
|
310
|
+
shell.setAttribute(slot.readyAttribute, 'true');
|
|
311
|
+
rootStyle.setProperty(`${slot.cssVariablePrefix}-left`, `${placement.left}px`);
|
|
312
|
+
rootStyle.setProperty(`${slot.cssVariablePrefix}-top`, `${placement.top}px`);
|
|
313
|
+
rootStyle.setProperty(`${slot.cssVariablePrefix}-width`, `${placement.width}px`);
|
|
314
|
+
}
|
|
315
|
+
function clearRailSlot(shell, slot) {
|
|
316
|
+
shell.removeAttribute(slot.readyAttribute);
|
|
317
|
+
const rootStyle = shell.ownerDocument.documentElement.style;
|
|
318
|
+
rootStyle.removeProperty(`${slot.cssVariablePrefix}-left`);
|
|
319
|
+
rootStyle.removeProperty(`${slot.cssVariablePrefix}-top`);
|
|
320
|
+
rootStyle.removeProperty(`${slot.cssVariablePrefix}-width`);
|
|
321
|
+
}
|
|
322
|
+
function applyBounds(element, bounds) {
|
|
323
|
+
element.style.display = '';
|
|
324
|
+
element.style.left = `${bounds.x}px`;
|
|
325
|
+
element.style.top = `${bounds.y}px`;
|
|
326
|
+
element.style.width = `${bounds.width}px`;
|
|
327
|
+
element.style.height = `${bounds.height}px`;
|
|
328
|
+
}
|
|
329
|
+
function applyOptionalBounds(element, bounds) {
|
|
330
|
+
if (bounds === undefined) {
|
|
331
|
+
element.style.display = 'none';
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
applyBounds(element, bounds);
|
|
335
|
+
}
|
|
336
|
+
function shellStateSignature(state) {
|
|
337
|
+
return JSON.stringify({
|
|
338
|
+
composition: state.composition,
|
|
339
|
+
layout: state.viewport.layout,
|
|
340
|
+
safeArea: state.viewport.safeArea,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function requireWindow(element) {
|
|
344
|
+
const view = element.ownerDocument.defaultView;
|
|
345
|
+
if (view === null) {
|
|
346
|
+
throw new Error('Adaptive shell needs a document with a window.');
|
|
347
|
+
}
|
|
348
|
+
return view;
|
|
349
|
+
}
|
|
350
|
+
function visualViewportOffset(element) {
|
|
351
|
+
const viewport = requireWindow(element).visualViewport;
|
|
352
|
+
if ((viewport === null || viewport === undefined) || measureTargetViewport({ visualViewport: viewport }) === null) {
|
|
353
|
+
return { x: 0, y: 0 };
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
x: Number.isFinite(viewport.offsetLeft) ? viewport.offsetLeft : 0,
|
|
357
|
+
y: Number.isFinite(viewport.offsetTop) ? viewport.offsetTop : 0,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Provider-neutral locations for non-executable asset-pack payloads. */
|
|
2
|
+
export type AssetPackLocation = 'packaged' | 'remote';
|
|
3
|
+
export interface AssetPackDeliveryPolicy {
|
|
4
|
+
readonly defaultLocation: AssetPackLocation;
|
|
5
|
+
readonly packs?: Readonly<Record<string, AssetPackLocation>>;
|
|
6
|
+
/** These packs and their dependency closure must work on first launch offline. */
|
|
7
|
+
readonly offlineRequired?: readonly string[];
|
|
8
|
+
/** HTTPS directory URL; HTTP is accepted only for loopback development. */
|
|
9
|
+
readonly remoteBaseUrl?: string;
|
|
10
|
+
/** Total packaged asset namespace, including manifest and policy metadata. */
|
|
11
|
+
readonly maxPackagedBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface AssetPackTargetPolicy extends AssetPackDeliveryPolicy {
|
|
14
|
+
/** Build config path relative to mpgd.targets.json. No credentials belong here. */
|
|
15
|
+
readonly buildConfig: string;
|
|
16
|
+
}
|
|
17
|
+
export interface AssetPackPolicyPack {
|
|
18
|
+
readonly packId: string;
|
|
19
|
+
readonly revision: string;
|
|
20
|
+
readonly dependencies: readonly {
|
|
21
|
+
readonly packId: string;
|
|
22
|
+
readonly revision: string;
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
25
|
+
export declare function assertAssetPackDeliveryPolicy(input: unknown): asserts input is AssetPackDeliveryPolicy;
|
|
26
|
+
export declare function assertAssetPackTargetPolicy(input: unknown): asserts input is AssetPackTargetPolicy;
|
|
27
|
+
/** Resolve the offline dependency closure; explicit remote conflicts fail closed. */
|
|
28
|
+
export declare function resolveAssetPackLocations(policy: AssetPackDeliveryPolicy, packs: readonly AssetPackPolicyPack[]): Readonly<Record<string, AssetPackLocation>>;
|
|
29
|
+
/** Compatible with Phaser delivery's resolveURL; its path is already encoded once. */
|
|
30
|
+
export declare function createAssetPackTargetURLResolver(policy: AssetPackDeliveryPolicy, packs: readonly AssetPackPolicyPack[], packagedBaseUrl: string): (encodedPath: string, context: {
|
|
31
|
+
readonly packId: string;
|
|
32
|
+
readonly revision: string;
|
|
33
|
+
}) => string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function assertAssetPackDeliveryPolicy(input) {
|
|
5
|
+
if (!record(input) || (input.defaultLocation !== 'packaged' && input.defaultLocation !== 'remote')) {
|
|
6
|
+
throw new Error('assetPacks.defaultLocation must be packaged or remote');
|
|
7
|
+
}
|
|
8
|
+
const keys = new Set([
|
|
9
|
+
'buildConfig',
|
|
10
|
+
'defaultLocation',
|
|
11
|
+
'packs',
|
|
12
|
+
'offlineRequired',
|
|
13
|
+
'remoteBaseUrl',
|
|
14
|
+
'maxPackagedBytes',
|
|
15
|
+
]);
|
|
16
|
+
if (Object.keys(input).some((key) => !keys.has(key))) {
|
|
17
|
+
throw new Error('assetPacks contains an unknown field; credentials are not target configuration');
|
|
18
|
+
}
|
|
19
|
+
if (input.packs !== undefined) {
|
|
20
|
+
if (!record(input.packs) || Object.keys(input.packs).length > 4096) {
|
|
21
|
+
throw new Error('assetPacks.packs must be a bounded pack-location map');
|
|
22
|
+
}
|
|
23
|
+
for (const [id, location] of Object.entries(input.packs)) {
|
|
24
|
+
if (!id || (location !== 'packaged' && location !== 'remote')) {
|
|
25
|
+
throw new Error('assetPacks.packs contains an invalid pack location');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (input.offlineRequired !== undefined && (!Array.isArray(input.offlineRequired)
|
|
30
|
+
|| input.offlineRequired.length > 4096
|
|
31
|
+
|| input.offlineRequired.some((id) => typeof id !== 'string' || !id))) {
|
|
32
|
+
throw new Error('assetPacks.offlineRequired must be a bounded list of pack IDs');
|
|
33
|
+
}
|
|
34
|
+
if (input.maxPackagedBytes !== undefined && (!Number.isSafeInteger(input.maxPackagedBytes)
|
|
35
|
+
|| input.maxPackagedBytes <= 0)) {
|
|
36
|
+
throw new Error('assetPacks.maxPackagedBytes must be a positive safe integer');
|
|
37
|
+
}
|
|
38
|
+
if (input.remoteBaseUrl !== undefined) {
|
|
39
|
+
if (typeof input.remoteBaseUrl !== 'string') {
|
|
40
|
+
throw new Error('assetPacks.remoteBaseUrl must be a directory URL');
|
|
41
|
+
}
|
|
42
|
+
const url = new URL(input.remoteBaseUrl);
|
|
43
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
44
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
|
|
45
|
+
|| url.username || url.password || url.search || url.hash || !url.pathname.endsWith('/')) {
|
|
46
|
+
throw new Error('assetPacks.remoteBaseUrl requires an HTTPS directory URL without credentials, query or fragment (HTTP loopback allowed)');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function assertAssetPackTargetPolicy(input) {
|
|
51
|
+
assertAssetPackDeliveryPolicy(input);
|
|
52
|
+
const buildConfig = input.buildConfig;
|
|
53
|
+
if (typeof buildConfig !== 'string' || !buildConfig.trim()) {
|
|
54
|
+
throw new Error('assetPacks.buildConfig must be a non-empty path');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Resolve the offline dependency closure; explicit remote conflicts fail closed. */
|
|
58
|
+
export function resolveAssetPackLocations(policy, packs) {
|
|
59
|
+
assertAssetPackDeliveryPolicy(policy);
|
|
60
|
+
const byId = new Map(packs.map((pack) => [pack.packId, pack]));
|
|
61
|
+
if (byId.size !== packs.length || packs.length > 4096) {
|
|
62
|
+
throw new Error('Asset pack policy requires unique, bounded pack IDs');
|
|
63
|
+
}
|
|
64
|
+
const locations = Object.create(null);
|
|
65
|
+
for (const pack of packs) {
|
|
66
|
+
locations[pack.packId] = policy.packs && Object.hasOwn(policy.packs, pack.packId)
|
|
67
|
+
? policy.packs[pack.packId] : policy.defaultLocation;
|
|
68
|
+
for (const dependency of pack.dependencies) {
|
|
69
|
+
if (byId.get(dependency.packId)?.revision !== dependency.revision) {
|
|
70
|
+
throw new Error(`Unknown dependency revision in pack ${pack.packId}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const id of Object.keys(policy.packs ?? {})) {
|
|
75
|
+
if (!byId.has(id)) {
|
|
76
|
+
throw new Error(`Unknown asset pack override: ${id}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const pending = [...(policy.offlineRequired ?? [])];
|
|
80
|
+
const visited = new Set();
|
|
81
|
+
while (pending.length) {
|
|
82
|
+
const id = pending.pop();
|
|
83
|
+
if (visited.has(id)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const pack = byId.get(id);
|
|
87
|
+
if (!pack) {
|
|
88
|
+
throw new Error(`Unknown offline-required asset pack: ${id}`);
|
|
89
|
+
}
|
|
90
|
+
if (policy.packs && Object.hasOwn(policy.packs, id) && policy.packs[id] === 'remote') {
|
|
91
|
+
throw new Error(`Offline-required dependency ${id} is explicitly remote`);
|
|
92
|
+
}
|
|
93
|
+
visited.add(id);
|
|
94
|
+
locations[id] = 'packaged';
|
|
95
|
+
pending.push(...pack.dependencies.map((dependency) => dependency.packId));
|
|
96
|
+
}
|
|
97
|
+
if (Object.values(locations).includes('remote') && policy.remoteBaseUrl === undefined) {
|
|
98
|
+
throw new Error('Remote asset packs require assetPacks.remoteBaseUrl');
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze(locations);
|
|
101
|
+
}
|
|
102
|
+
/** Compatible with Phaser delivery's resolveURL; its path is already encoded once. */
|
|
103
|
+
export function createAssetPackTargetURLResolver(policy, packs, packagedBaseUrl) {
|
|
104
|
+
const locations = resolveAssetPackLocations(policy, packs);
|
|
105
|
+
const revisions = new Map(packs.map((pack) => [pack.packId, pack.revision]));
|
|
106
|
+
const local = new URL(packagedBaseUrl);
|
|
107
|
+
if (!local.pathname.endsWith('/') || local.username || local.password || local.search || local.hash) {
|
|
108
|
+
throw new Error('Packaged asset base must be a directory URL');
|
|
109
|
+
}
|
|
110
|
+
const remote = policy.remoteBaseUrl;
|
|
111
|
+
return (encodedPath, context) => {
|
|
112
|
+
if (revisions.get(context.packId) !== context.revision) {
|
|
113
|
+
throw new Error('Asset URL context does not match the selected pack revision');
|
|
114
|
+
}
|
|
115
|
+
if (!encodedPath || encodedPath.startsWith('/') || /[?#:\\]/.test(encodedPath)
|
|
116
|
+
|| encodedPath.split('/').some((part) => !part || ['.', '..'].includes(decodeURIComponent(part))
|
|
117
|
+
|| /[/\\]/.test(decodeURIComponent(part)))) {
|
|
118
|
+
throw new Error('Asset URL requires an encoded relative artifact path');
|
|
119
|
+
}
|
|
120
|
+
return new URL(encodedPath, locations[context.packId] === 'remote' ? remote : local).href;
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/effective.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type AdPlacementEntry, type AdPlacements, type ProductCatalog, type ProductCatalogEntry } from '@mpgd/catalog';
|
|
2
2
|
import { type FeatureAvailabilityReason, type TargetCapabilityConfig, type TargetConfig, type TargetConfigMatrix, type TargetFeatureConfig, type TargetIntegrationConfig, type TargetLocalizationConfig, type TargetPolicyRestrictions, type TargetReleaseConfig, type TargetRuntimeKind } from './runtime.js';
|
|
3
|
+
import { type AssetPackDeliveryPolicy, type AssetPackTargetPolicy } from './asset-packs.js';
|
|
3
4
|
export type EffectiveAvailabilityReason = FeatureAvailabilityReason | 'missing-platform-id';
|
|
4
5
|
export interface EffectivePlatformTargetMetadata {
|
|
6
|
+
readonly assetPacks?: AssetPackTargetPolicy;
|
|
5
7
|
readonly kind: string;
|
|
6
8
|
readonly adapter: string;
|
|
7
9
|
readonly crazyGamesLaunch?: 'basic' | 'full';
|
|
@@ -61,6 +63,7 @@ export interface EffectiveLocalizationConfig {
|
|
|
61
63
|
readonly fallbackLocale: TargetLocalizationConfig['fallbackLocale'];
|
|
62
64
|
}
|
|
63
65
|
export interface EffectiveTargetConfig {
|
|
66
|
+
readonly assetPacks?: AssetPackDeliveryPolicy;
|
|
64
67
|
readonly version: string;
|
|
65
68
|
readonly target: string;
|
|
66
69
|
readonly runtime: TargetRuntimeKind;
|
package/dist/effective.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveAdPlacementPlatformId, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
2
2
|
import { adPlacementFeatureFor, assertTargetIntegrationRuntimeBounds, } from './runtime.js';
|
|
3
|
+
import { assertAssetPackTargetPolicy, } from './asset-packs.js';
|
|
3
4
|
export const defaultLeaderboardId = 'default';
|
|
4
5
|
const disabledAuthoritativeMonetization = {
|
|
5
6
|
iap: false,
|
|
@@ -10,6 +11,17 @@ const disabledAuthoritativeMonetization = {
|
|
|
10
11
|
};
|
|
11
12
|
export function createEffectiveTargetConfig(input) {
|
|
12
13
|
const config = resolveAuthoritativeGameServicesConfig(input.config, input.platformTarget);
|
|
14
|
+
if (input.platformTarget?.assetPacks !== undefined) {
|
|
15
|
+
assertAssetPackTargetPolicy(input.platformTarget.assetPacks);
|
|
16
|
+
}
|
|
17
|
+
const assetPolicy = input.platformTarget?.assetPacks;
|
|
18
|
+
const runtimeAssetPacks = assetPolicy === undefined ? undefined : {
|
|
19
|
+
defaultLocation: assetPolicy.defaultLocation,
|
|
20
|
+
...(assetPolicy.packs === undefined ? {} : { packs: { ...assetPolicy.packs } }),
|
|
21
|
+
...(assetPolicy.offlineRequired === undefined ? {} : { offlineRequired: [...assetPolicy.offlineRequired] }),
|
|
22
|
+
...(assetPolicy.remoteBaseUrl === undefined ? {} : { remoteBaseUrl: assetPolicy.remoteBaseUrl }),
|
|
23
|
+
...(assetPolicy.maxPackagedBytes === undefined ? {} : { maxPackagedBytes: assetPolicy.maxPackagedBytes }),
|
|
24
|
+
};
|
|
13
25
|
const products = input.catalog.products.map((product) => createEffectiveProductConfig(input.target, config, product));
|
|
14
26
|
const placements = input.adPlacements.placements.map((placement) => createEffectiveAdPlacementConfig(input.target, config, placement));
|
|
15
27
|
const leaderboardEnabled = config.features.leaderboard;
|
|
@@ -24,6 +36,7 @@ export function createEffectiveTargetConfig(input) {
|
|
|
24
36
|
adPlacements: input.adPlacements.version,
|
|
25
37
|
}),
|
|
26
38
|
target: input.target,
|
|
39
|
+
...(runtimeAssetPacks === undefined ? {} : { assetPacks: runtimeAssetPacks }),
|
|
27
40
|
runtime: config.runtime,
|
|
28
41
|
release: config.release,
|
|
29
42
|
features: config.features,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EffectiveTargetConfigMatrix } from './effective.js';
|
|
2
2
|
import type { TargetConfigMatrix } from './runtime.js';
|
|
3
3
|
export * from './effective.js';
|
|
4
|
+
export * from './asset-packs.js';
|
|
4
5
|
export * from './platformVersionAllocation.js';
|
|
5
6
|
export * from './releaseIdentity.js';
|
|
6
7
|
export * from './runtime.js';
|