@hopple/sdk 0.1.0-rc.2 → 1.0.0-next.1

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/manifest.js ADDED
@@ -0,0 +1,851 @@
1
+ /*! @hopple/sdk 1.0.0-next.1 | Copyright (c) 2026 The Hopple authors | MIT License */
2
+ //#region ../manifest/src/manifest.schema.ts
3
+ const GameManifest = {
4
+ "type": "object",
5
+ "properties": {
6
+ "api": { "$ref": "#/definitions/ApiVersion" },
7
+ "id": {
8
+ "type": "string",
9
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
10
+ "maxLength": 64,
11
+ "errorMessage": "must be lowercase letters, digits and hyphens"
12
+ },
13
+ "owner": {
14
+ "type": "string",
15
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
16
+ "maxLength": 64,
17
+ "errorMessage": "must be a handle: lowercase letters, digits and hyphens"
18
+ },
19
+ "version": {
20
+ "type": "string",
21
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
22
+ "errorMessage": "must look like 1.0.0"
23
+ },
24
+ "title": {
25
+ "type": "string",
26
+ "pattern": "\\S",
27
+ "maxLength": 60,
28
+ "errorMessage": "must not be blank"
29
+ },
30
+ "description": {
31
+ "type": "string",
32
+ "pattern": "\\S",
33
+ "maxLength": 500,
34
+ "errorMessage": "must not be blank"
35
+ },
36
+ "cover": {
37
+ "type": "string",
38
+ "pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+\\.(?:png|jpe?g|webp)$",
39
+ "maxLength": 200,
40
+ "errorMessage": "must be the path of a .png, .jpg or .webp file beside the config, like \"cover.png\""
41
+ },
42
+ "orientation": { "$ref": "#/definitions/Orientation" },
43
+ "layout": { "$ref": "#/definitions/LayoutDeclaration" },
44
+ "analytics": { "$ref": "#/definitions/AnalyticsDeclaration" },
45
+ "save": { "$ref": "#/definitions/SaveDeclaration" },
46
+ "score": { "$ref": "#/definitions/ScoreDeclaration" },
47
+ "achievements": { "$ref": "#/definitions/AchievementDeclaration" },
48
+ "input": { "$ref": "#/definitions/InputDeclaration" },
49
+ "players": { "$ref": "#/definitions/PlayersDeclaration" },
50
+ "audio": { "anyOf": [{ "$ref": "#/definitions/AudioDeclaration" }, { "type": "null" }] },
51
+ "previews": { "$ref": "#/definitions/PreviewsDeclaration" }
52
+ },
53
+ "required": [
54
+ "api",
55
+ "id",
56
+ "version",
57
+ "title",
58
+ "orientation",
59
+ "audio"
60
+ ],
61
+ "additionalProperties": false
62
+ };
63
+ const ApiVersion = {
64
+ "type": "string",
65
+ "pattern": "^(?:(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)|next)$",
66
+ "errorMessage": "must be the API the game is written against, like \"1.0\", or \"next\""
67
+ };
68
+ const Orientation = {
69
+ "type": "string",
70
+ "enum": [
71
+ "portrait",
72
+ "landscape",
73
+ "any"
74
+ ]
75
+ };
76
+ const LayoutDeclaration = {
77
+ "type": "object",
78
+ "properties": { "insets": { "$ref": "#/definitions/InsetMode" } },
79
+ "required": ["insets"],
80
+ "additionalProperties": false
81
+ };
82
+ const InsetMode = {
83
+ "type": "string",
84
+ "enum": ["draw-under", "reserve"]
85
+ };
86
+ const AnalyticsDeclaration = {
87
+ "type": "object",
88
+ "properties": { "events": {
89
+ "type": "object",
90
+ "additionalProperties": { "$ref": "#/definitions/AnalyticsShape" },
91
+ "maxProperties": 64,
92
+ "propertyNames": {
93
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
94
+ "maxLength": 48,
95
+ "not": { "pattern": "^hopple\\." },
96
+ "errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores, and must not start with \"hopple.\", which the host uses"
97
+ }
98
+ } },
99
+ "required": ["events"],
100
+ "additionalProperties": false
101
+ };
102
+ const AnalyticsShape = {
103
+ "type": "object",
104
+ "additionalProperties": { "$ref": "#/definitions/AnalyticsType" },
105
+ "maxProperties": 8,
106
+ "propertyNames": {
107
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
108
+ "maxLength": 24,
109
+ "errorMessage": "must be a prop name of lowercase letters, digits, dots, hyphens and underscores"
110
+ }
111
+ };
112
+ const AnalyticsType = {
113
+ "type": "string",
114
+ "enum": [
115
+ "string",
116
+ "number",
117
+ "boolean"
118
+ ]
119
+ };
120
+ const SaveDeclaration = {
121
+ "type": "object",
122
+ "properties": { "bytes": {
123
+ "type": "integer",
124
+ "exclusiveMinimum": 0,
125
+ "maximum": 1048576
126
+ } },
127
+ "required": ["bytes"],
128
+ "additionalProperties": false
129
+ };
130
+ const ScoreDeclaration = {
131
+ "type": "object",
132
+ "properties": { "boards": {
133
+ "type": "object",
134
+ "additionalProperties": { "$ref": "#/definitions/ScoreBoard" },
135
+ "minProperties": 1,
136
+ "maxProperties": 16,
137
+ "propertyNames": {
138
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
139
+ "maxLength": 48,
140
+ "errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
141
+ }
142
+ } },
143
+ "required": ["boards"],
144
+ "additionalProperties": false
145
+ };
146
+ const ScoreBoard = {
147
+ "type": "object",
148
+ "properties": {
149
+ "title": {
150
+ "type": "string",
151
+ "pattern": "\\S",
152
+ "maxLength": 40,
153
+ "errorMessage": "must not be blank"
154
+ },
155
+ "direction": { "$ref": "#/definitions/ScoreDirection" },
156
+ "format": { "$ref": "#/definitions/ScoreFormat" }
157
+ },
158
+ "required": ["title", "direction"],
159
+ "additionalProperties": false
160
+ };
161
+ const ScoreDirection = {
162
+ "type": "string",
163
+ "enum": ["asc", "desc"]
164
+ };
165
+ const ScoreFormat = {
166
+ "type": "string",
167
+ "enum": [
168
+ "integer",
169
+ "decimal",
170
+ "duration-ms"
171
+ ]
172
+ };
173
+ const AchievementDeclaration = {
174
+ "type": "object",
175
+ "properties": { "list": {
176
+ "type": "object",
177
+ "additionalProperties": { "$ref": "#/definitions/Achievement" },
178
+ "minProperties": 1,
179
+ "maxProperties": 128,
180
+ "propertyNames": {
181
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
182
+ "maxLength": 48,
183
+ "errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
184
+ }
185
+ } },
186
+ "required": ["list"],
187
+ "additionalProperties": false
188
+ };
189
+ const Achievement = {
190
+ "type": "object",
191
+ "properties": {
192
+ "title": {
193
+ "type": "string",
194
+ "pattern": "\\S",
195
+ "maxLength": 40,
196
+ "errorMessage": "must not be blank"
197
+ },
198
+ "detail": {
199
+ "type": "string",
200
+ "pattern": "\\S",
201
+ "maxLength": 120,
202
+ "errorMessage": "must not be blank"
203
+ },
204
+ "icon": {
205
+ "type": "string",
206
+ "pattern": "\\S",
207
+ "maxLength": 8,
208
+ "errorMessage": "must not be blank"
209
+ },
210
+ "secret": { "type": "boolean" },
211
+ "progress": { "$ref": "#/definitions/AchievementProgress" }
212
+ },
213
+ "required": ["title"],
214
+ "additionalProperties": false
215
+ };
216
+ const AchievementProgress = {
217
+ "type": "object",
218
+ "properties": {
219
+ "target": {
220
+ "type": "number",
221
+ "minimum": -9007199254740991,
222
+ "maximum": 9007199254740991
223
+ },
224
+ "direction": { "$ref": "#/definitions/ScoreDirection" },
225
+ "format": { "$ref": "#/definitions/ScoreFormat" },
226
+ "tiers": {
227
+ "type": "array",
228
+ "items": { "$ref": "#/definitions/AchievementTier" },
229
+ "maxItems": 8
230
+ }
231
+ },
232
+ "required": ["target"],
233
+ "additionalProperties": false
234
+ };
235
+ const AchievementTier = {
236
+ "type": "object",
237
+ "properties": {
238
+ "at": {
239
+ "type": "number",
240
+ "minimum": -9007199254740991,
241
+ "maximum": 9007199254740991
242
+ },
243
+ "title": {
244
+ "type": "string",
245
+ "pattern": "\\S",
246
+ "maxLength": 40,
247
+ "errorMessage": "must not be blank"
248
+ },
249
+ "icon": {
250
+ "type": "string",
251
+ "pattern": "\\S",
252
+ "maxLength": 8,
253
+ "errorMessage": "must not be blank"
254
+ }
255
+ },
256
+ "required": ["at", "title"],
257
+ "additionalProperties": false
258
+ };
259
+ const InputDeclaration = {
260
+ "type": "object",
261
+ "properties": {
262
+ "supported": {
263
+ "type": "array",
264
+ "items": { "$ref": "#/definitions/InputDeviceSet" },
265
+ "minItems": 1,
266
+ "maxItems": 8
267
+ },
268
+ "actions": {
269
+ "type": "object",
270
+ "additionalProperties": { "$ref": "#/definitions/InputAction" },
271
+ "maxProperties": 64,
272
+ "propertyNames": {
273
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
274
+ "maxLength": 48,
275
+ "errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
276
+ }
277
+ },
278
+ "controls": {
279
+ "type": "object",
280
+ "additionalProperties": { "$ref": "#/definitions/ControlLayout" },
281
+ "maxProperties": 8,
282
+ "propertyNames": {
283
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
284
+ "maxLength": 48,
285
+ "errorMessage": "must be a layout name of lowercase letters, digits, dots, hyphens and underscores"
286
+ }
287
+ },
288
+ "player": { "$ref": "#/definitions/PlayerInputDeclaration" }
289
+ },
290
+ "additionalProperties": false
291
+ };
292
+ const InputDeviceSet = {
293
+ "type": "array",
294
+ "items": { "$ref": "#/definitions/InputDevice" },
295
+ "minItems": 1,
296
+ "maxItems": 8
297
+ };
298
+ const InputDevice = {
299
+ "type": "string",
300
+ "enum": [
301
+ "keyboard",
302
+ "mouse",
303
+ "touch",
304
+ "gamepad"
305
+ ]
306
+ };
307
+ const InputAction = {
308
+ "type": "object",
309
+ "properties": {
310
+ "bind": { "$ref": "#/definitions/InputBindings" },
311
+ "title": {
312
+ "type": "string",
313
+ "pattern": "\\S",
314
+ "maxLength": 40,
315
+ "errorMessage": "must not be blank"
316
+ },
317
+ "label": {
318
+ "type": "string",
319
+ "pattern": "\\S",
320
+ "maxLength": 8,
321
+ "errorMessage": "must not be blank"
322
+ }
323
+ },
324
+ "required": ["bind"],
325
+ "additionalProperties": false
326
+ };
327
+ const InputBindings = {
328
+ "type": "array",
329
+ "items": { "$ref": "#/definitions/InputBinding" },
330
+ "minItems": 1,
331
+ "maxItems": 8
332
+ };
333
+ const InputBinding = {
334
+ "type": "string",
335
+ "pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|pointer\\.(?:primary|secondary)|mouse\\.(?:left|middle|right)|touch\\.tap|controls\\.(?=.{1,48}$)[a-z0-9]+(?:[_-][a-z0-9]+)*|pad\\.(?:north|south|east|west|start|select)|pad\\.(?:left|right)\\.(?:north|south|east|west|bumper|trigger))$",
336
+ "errorMessage": "is not a binding this SDK knows (a thumbstick is read rather than bound, and Escape is the host's key)"
337
+ };
338
+ const ControlLayout = {
339
+ "type": "object",
340
+ "properties": {
341
+ "left": { "$ref": "#/definitions/ControlZoneSlots" },
342
+ "right": { "$ref": "#/definitions/ControlZoneSlots" },
343
+ "full": { "$ref": "#/definitions/ControlZoneSlots" }
344
+ },
345
+ "additionalProperties": false
346
+ };
347
+ const ControlZoneSlots = {
348
+ "type": "object",
349
+ "additionalProperties": { "$ref": "#/definitions/ControlSlot" },
350
+ "maxProperties": 8,
351
+ "propertyNames": {
352
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
353
+ "maxLength": 48,
354
+ "errorMessage": "must be a control id of lowercase letters, digits, dots, hyphens and underscores"
355
+ }
356
+ };
357
+ const ControlSlot = {
358
+ "type": "object",
359
+ "properties": {
360
+ "type": {
361
+ "type": "string",
362
+ "enum": ["buttons", "thumbstick"]
363
+ },
364
+ "buttons": {
365
+ "type": "array",
366
+ "items": { "type": "string" },
367
+ "minItems": 1,
368
+ "maxItems": 8
369
+ },
370
+ "shape": { "$ref": "#/definitions/ControlShape" },
371
+ "as": { "anyOf": [{ "$ref": "#/definitions/InputStick" }, {
372
+ "type": "object",
373
+ "additionalProperties": { "$ref": "#/definitions/InputBinding" }
374
+ }] },
375
+ "axis": { "$ref": "#/definitions/ControlAxis" }
376
+ },
377
+ "required": ["type"],
378
+ "additionalProperties": false,
379
+ "if": {
380
+ "properties": { "type": { "const": "buttons" } },
381
+ "required": ["type"]
382
+ },
383
+ "then": {
384
+ "required": ["buttons"],
385
+ "properties": {
386
+ "as": {
387
+ "type": "object",
388
+ "additionalProperties": { "$ref": "#/definitions/InputBinding" }
389
+ },
390
+ "axis": {
391
+ "not": {},
392
+ "errorMessage": "is only for a thumbstick"
393
+ }
394
+ }
395
+ },
396
+ "else": { "properties": { "as": { "$ref": "#/definitions/InputStick" } } }
397
+ };
398
+ const ControlShape = {
399
+ "type": "string",
400
+ "enum": [
401
+ "row",
402
+ "column",
403
+ "diamond",
404
+ "cluster"
405
+ ]
406
+ };
407
+ const InputStick = {
408
+ "type": "string",
409
+ "pattern": "^(?:controls\\.(?=.{1,48}$)[a-z0-9]+(?:[_-][a-z0-9]+)*|pad\\.stick|pad\\.(?:left|right)\\.stick)$",
410
+ "errorMessage": "must name a thumbstick, such as pad.left.stick"
411
+ };
412
+ const definitions = {
413
+ GameManifest,
414
+ ApiVersion,
415
+ Orientation,
416
+ LayoutDeclaration,
417
+ InsetMode,
418
+ AnalyticsDeclaration,
419
+ AnalyticsShape,
420
+ AnalyticsType,
421
+ SaveDeclaration,
422
+ ScoreDeclaration,
423
+ ScoreBoard,
424
+ ScoreDirection,
425
+ ScoreFormat,
426
+ AchievementDeclaration,
427
+ Achievement,
428
+ AchievementProgress,
429
+ AchievementTier,
430
+ InputDeclaration,
431
+ InputDeviceSet,
432
+ InputDevice,
433
+ InputAction,
434
+ InputBindings,
435
+ InputBinding,
436
+ ControlLayout,
437
+ ControlZoneSlots,
438
+ ControlSlot,
439
+ ControlShape,
440
+ InputStick,
441
+ ControlAxis: {
442
+ "type": "string",
443
+ "enum": ["x", "y"]
444
+ },
445
+ PlayerInputDeclaration: {
446
+ "type": "object",
447
+ "properties": {
448
+ "actions": {
449
+ "type": "object",
450
+ "additionalProperties": { "$ref": "#/definitions/PlayerInputAction" },
451
+ "maxProperties": 32,
452
+ "propertyNames": {
453
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
454
+ "maxLength": 48,
455
+ "errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
456
+ }
457
+ },
458
+ "keyboard": {
459
+ "type": "array",
460
+ "items": { "$ref": "#/definitions/KeyboardSet" },
461
+ "minItems": 1,
462
+ "maxItems": 4
463
+ }
464
+ },
465
+ "required": ["actions"],
466
+ "additionalProperties": false
467
+ },
468
+ PlayerInputAction: {
469
+ "type": "object",
470
+ "properties": {
471
+ "bind": {
472
+ "type": "array",
473
+ "items": { "$ref": "#/definitions/PlayerBinding" },
474
+ "minItems": 1,
475
+ "maxItems": 8
476
+ },
477
+ "title": {
478
+ "type": "string",
479
+ "pattern": "\\S",
480
+ "maxLength": 40,
481
+ "errorMessage": "must not be blank"
482
+ },
483
+ "label": {
484
+ "type": "string",
485
+ "pattern": "\\S",
486
+ "maxLength": 8,
487
+ "errorMessage": "must not be blank"
488
+ },
489
+ "join": { "type": "boolean" }
490
+ },
491
+ "additionalProperties": false
492
+ },
493
+ PlayerBinding: {
494
+ "type": "string",
495
+ "pattern": "^(?:controls\\.(?=.{1,48}$)[a-z0-9]+(?:[_-][a-z0-9]+)*|pad\\.(?:north|south|east|west|start|select)|pad\\.(?:left|right)\\.(?:north|south|east|west|bumper|trigger))$",
496
+ "errorMessage": "must be a pad button or an on-screen control: a player's keys and mouse buttons go in input.player.keyboard"
497
+ },
498
+ KeyboardSet: {
499
+ "type": "object",
500
+ "properties": {
501
+ "title": {
502
+ "type": "string",
503
+ "pattern": "\\S",
504
+ "maxLength": 40,
505
+ "errorMessage": "must not be blank"
506
+ },
507
+ "bind": {
508
+ "type": "object",
509
+ "additionalProperties": {
510
+ "type": "array",
511
+ "items": { "$ref": "#/definitions/KeyboardBinding" }
512
+ },
513
+ "maxProperties": 64,
514
+ "propertyNames": {
515
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
516
+ "maxLength": 48,
517
+ "errorMessage": "must be a player action id"
518
+ }
519
+ }
520
+ },
521
+ "required": ["bind"],
522
+ "additionalProperties": false
523
+ },
524
+ KeyboardBinding: {
525
+ "type": "string",
526
+ "pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|mouse\\.(?:left|middle|right))$",
527
+ "errorMessage": "must be a key or a mouse button (and Escape is the host's key)"
528
+ },
529
+ PlayersDeclaration: {
530
+ "type": "object",
531
+ "properties": { "local": {
532
+ "type": "integer",
533
+ "minimum": 1,
534
+ "maximum": 8
535
+ } },
536
+ "additionalProperties": false
537
+ },
538
+ AudioDeclaration: {
539
+ "type": "object",
540
+ "properties": { "channels": {
541
+ "type": "object",
542
+ "additionalProperties": { "$ref": "#/definitions/AudioChannel" },
543
+ "maxProperties": 8,
544
+ "propertyNames": {
545
+ "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
546
+ "maxLength": 48,
547
+ "not": { "const": "master" },
548
+ "errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores, and must not be \"master\", which every host adds itself"
549
+ }
550
+ } },
551
+ "additionalProperties": false
552
+ },
553
+ AudioChannel: {
554
+ "type": "object",
555
+ "properties": { "title": {
556
+ "type": "string",
557
+ "pattern": "\\S",
558
+ "maxLength": 40,
559
+ "errorMessage": "must not be blank"
560
+ } },
561
+ "required": ["title"],
562
+ "additionalProperties": false
563
+ },
564
+ PreviewsDeclaration: {
565
+ "type": "object",
566
+ "properties": { "network": { "$ref": "#/definitions/PreviewRevision" } },
567
+ "additionalProperties": false
568
+ },
569
+ PreviewRevision: {
570
+ "type": "integer",
571
+ "minimum": 1
572
+ }
573
+ };
574
+ AchievementProgress.properties.target.maximum;
575
+ GameManifest.required;
576
+ /**
577
+ * The zones a layout places controls in, in the order they are drawn.
578
+ *
579
+ * @group Input
580
+ */
581
+ const CONTROL_ZONES = Object.keys(ControlLayout.properties);
582
+ new RegExp(InputBinding.pattern);
583
+ new RegExp(InputStick.pattern);
584
+ /**
585
+ * Check a parsed manifest, without throwing and without changing it.
586
+ *
587
+ * Every problem is reported, not just the first: someone fixing a manifest
588
+ * wants the list, and a validator that reveals one error per run is a validator
589
+ * people stop running. A key the manifest does not know is refused, since a
590
+ * misspelt one would otherwise mean nothing in silence; `lenient` ignores it
591
+ * instead, for reading a manifest that was published, possibly against a newer
592
+ * minor than this one. A key we *do* know, spelled wrongly, is refused either
593
+ * way: `"draw_under"` would otherwise mean `reserve` in silence, and silence
594
+ * here looks exactly like a bug in the host.
595
+ *
596
+ * @group Manifest
597
+ * @category Important
598
+ */
599
+ function validateManifest(value, options = {}) {
600
+ const problems = [];
601
+ const fail = (field, message) => {
602
+ if (problems.some((p) => p.field === field && p.message === message)) return;
603
+ problems.push({
604
+ field,
605
+ message
606
+ });
607
+ };
608
+ walk(GameManifest, value, "", fail, options.lenient === true);
609
+ if (problems.length === 0) checkReferences(value, fail);
610
+ if (problems.length > 0) return {
611
+ ok: false,
612
+ problems
613
+ };
614
+ return {
615
+ ok: true,
616
+ manifest: value
617
+ };
618
+ }
619
+ /**
620
+ * One line per problem, worded the same wherever a manifest is rejected.
621
+ *
622
+ * Field names are set in backticks, because plenty of them are ordinary words
623
+ * — "entry is required" reads as a sentence about some entry, and
624
+ * "`entry` is required" names a key.
625
+ *
626
+ * @group Manifest
627
+ * @category Useful
628
+ */
629
+ function describeProblems(problems) {
630
+ return problems.map((p) => p.field === "" ? `the manifest ${p.message}` : `\`${p.field}\` ${p.message}`).join("; ");
631
+ }
632
+ function resolve(node) {
633
+ let at = node;
634
+ while (typeof at.$ref === "string") {
635
+ const name = at.$ref.slice(14);
636
+ const found = definitions[name];
637
+ if (found === void 0) throw new Error(`the manifest schema has no definition ${name}`);
638
+ at = found;
639
+ }
640
+ return at;
641
+ }
642
+ /** Whether a value passes, with nothing reported. */
643
+ function passes(node, value, lenient) {
644
+ let ok = true;
645
+ walk(node, value, "", () => {
646
+ ok = false;
647
+ }, lenient);
648
+ return ok;
649
+ }
650
+ function kindOf(value) {
651
+ if (Array.isArray(value)) return "array";
652
+ if (value === null) return "null";
653
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
654
+ return typeof value;
655
+ }
656
+ function isKind(value, type) {
657
+ const kind = kindOf(value);
658
+ return kind === type || type === "number" && kind === "integer";
659
+ }
660
+ const KIND_WORDS = {
661
+ string: "a string",
662
+ number: "a number",
663
+ integer: "a whole number",
664
+ boolean: "true or false",
665
+ object: "an object",
666
+ array: "a list",
667
+ null: "null"
668
+ };
669
+ function child(path, key, max = 48) {
670
+ const name = [...key].length > max ? `${[...key].slice(0, max).join("")}…` : key;
671
+ return path === "" ? name : `${path}.${name}`;
672
+ }
673
+ /** One value against one schema node, reporting what is wrong where it is wrong. */
674
+ /** `lenient` ignores keys an object's schema does not name, rather than refusing them. */
675
+ function walk(given, value, path, fail, lenient) {
676
+ const node = resolve(given);
677
+ if (Array.isArray(node.anyOf)) {
678
+ const branches = node.anyOf.map(resolve);
679
+ if (branches.some((branch) => passes(branch, value, lenient))) return;
680
+ const shaped = branches.find((branch) => isKind(value, branch.type));
681
+ if (shaped !== void 0) walk(shaped, value, path, fail, lenient);
682
+ else fail(path, `must be ${branches.map((branch) => KIND_WORDS[String(branch.type)]).join(" or ")}`);
683
+ return;
684
+ }
685
+ if (node.type !== void 0 && !isKind(value, node.type)) {
686
+ fail(path, `must be ${KIND_WORDS[String(node.type)] ?? String(node.type)}`);
687
+ return;
688
+ }
689
+ if ("const" in node && value !== node.const) {
690
+ fail(path, `must be ${JSON.stringify(node.const)}`);
691
+ return;
692
+ }
693
+ if (Array.isArray(node.enum) && !node.enum.includes(value)) {
694
+ fail(path, `must be one of ${node.enum.join(", ")}`);
695
+ return;
696
+ }
697
+ const message = typeof node.errorMessage === "string" ? node.errorMessage : void 0;
698
+ if (typeof value === "string") {
699
+ const length = [...value].length;
700
+ if (typeof node.pattern === "string" && !new RegExp(node.pattern, "u").test(value)) fail(path, message ?? `must match ${node.pattern}`);
701
+ else if (typeof node.maxLength === "number" && length > node.maxLength) fail(path, `must be at most ${node.maxLength} characters`);
702
+ else if (typeof node.minLength === "number" && length < node.minLength) fail(path, `must be at least ${node.minLength} characters`);
703
+ }
704
+ if (typeof value === "number") {
705
+ if (typeof node.minimum === "number" && value < node.minimum) fail(path, `must be at least ${node.minimum}`);
706
+ else if (typeof node.exclusiveMinimum === "number" && value <= node.exclusiveMinimum) fail(path, `must be above ${node.exclusiveMinimum}`);
707
+ else if (typeof node.maximum === "number" && value > node.maximum) fail(path, `must be at most ${node.maximum}`);
708
+ else if (typeof node.exclusiveMaximum === "number" && value >= node.exclusiveMaximum) fail(path, `must be below ${node.exclusiveMaximum}`);
709
+ }
710
+ if (node.not !== void 0 && passes(node.not, value, lenient)) fail(path, message ?? "is not allowed");
711
+ if (Array.isArray(value)) walkArray(node, value, path, fail, lenient);
712
+ else if (kindOf(value) === "object") walkObject(node, value, path, fail, lenient);
713
+ if (node.if !== void 0) {
714
+ const branch = passes(node.if, value, lenient) ? node.then : node.else;
715
+ if (branch !== void 0) walk(branch, value, path, fail, lenient);
716
+ }
717
+ if (Array.isArray(node.allOf)) for (const part of node.allOf) walk(part, value, path, fail, lenient);
718
+ }
719
+ function walkArray(node, value, path, fail, lenient) {
720
+ if (typeof node.minItems === "number" && value.length < node.minItems) fail(path, node.minItems === 1 ? "must not be empty" : `must hold at least ${node.minItems}`);
721
+ else if (typeof node.maxItems === "number" && value.length > node.maxItems) fail(path, `must hold at most ${node.maxItems}`);
722
+ if (node.items === void 0) return;
723
+ for (const [index, item] of value.entries()) if (typeof item === "object" && item !== null) walk(node.items, item, `${path}[${index}]`, fail, lenient);
724
+ else walk(node.items, item, path, (field, message) => {
725
+ fail(field, field === path ? `${JSON.stringify(item)} ${message}` : message);
726
+ }, lenient);
727
+ }
728
+ function walkObject(node, value, path, fail, lenient) {
729
+ const keys = Object.keys(value);
730
+ if (typeof node.minProperties === "number" && keys.length < node.minProperties) fail(path, node.minProperties === 1 ? "must declare at least one" : `must declare at least ${node.minProperties}`);
731
+ else if (typeof node.maxProperties === "number" && keys.length > node.maxProperties) fail(path, `must declare at most ${node.maxProperties}`);
732
+ const names = node.propertyNames;
733
+ const clip = typeof names?.maxLength === "number" ? names.maxLength : void 0;
734
+ const properties = node.properties ?? {};
735
+ const required = node.required ?? [];
736
+ for (const [key, property] of Object.entries(properties)) if (key in value) walk(property, value[key], child(path, key), fail, lenient);
737
+ else if (required.includes(key)) fail(child(path, key), "is required");
738
+ for (const key of keys) {
739
+ if (key in properties) continue;
740
+ const at = child(path, key, clip);
741
+ if (names !== void 0) {
742
+ let first;
743
+ walk(names, key, "", (_field, message) => {
744
+ first ??= message;
745
+ }, lenient);
746
+ if (first !== void 0) fail(at, first);
747
+ }
748
+ if (typeof node.additionalProperties === "object" && node.additionalProperties !== null) walk(node.additionalProperties, value[key], at, fail, lenient);
749
+ else if (node.additionalProperties === false && !lenient) fail(at, "is not a key the manifest knows");
750
+ }
751
+ }
752
+ function checkReferences(manifest, fail) {
753
+ const players = manifest.input?.player;
754
+ const own = new Set(Object.keys(players?.actions ?? {}));
755
+ const actions = /* @__PURE__ */ new Set([...Object.keys(manifest.input?.actions ?? {}), ...own]);
756
+ for (const id of own) if (manifest.input?.actions?.[id] !== void 0) fail(`input.player.actions.${id}`, "is an id input.actions declares too");
757
+ /** Which set each key or mouse button already presses something in, so two players never share one. */
758
+ const taken = /* @__PURE__ */ new Map();
759
+ for (const [index, set] of (players?.keyboard ?? []).entries()) for (const [id, bind] of Object.entries(set.bind)) {
760
+ const at = `input.player.keyboard[${index}].bind.${id}`;
761
+ if (!own.has(id)) fail(at, `names no player action "${id}"`);
762
+ for (const binding of bind) {
763
+ const before = taken.get(binding);
764
+ if (before !== void 0 && before !== index) fail(at, `binds ${binding}, which keyboard set ${before} binds too: a press would be two players'`);
765
+ taken.set(binding, index);
766
+ }
767
+ }
768
+ for (const id of own) if (!((players?.actions[id]?.bind?.length ?? 0) > 0 || (players?.keyboard ?? []).some((set) => (set.bind[id]?.length ?? 0) > 0) || Object.values(controlLayouts(manifest)).some((layout) => layoutActions(layout).includes(id)))) fail(`input.player.actions.${id}`, "is pressed by nothing: bind it, give it keys in a keyboard set, or put it on an on-screen control");
769
+ if ((manifest.players?.local ?? 1) > 1 && players === void 0) fail("players.local", "needs input.player: the actions each of the players has");
770
+ for (const [name, layout] of Object.entries(controlLayouts(manifest))) for (const zone of CONTROL_ZONES) for (const [id, slot] of Object.entries(layout[zone] ?? {})) {
771
+ if (slot.type !== "buttons" || slot.buttons === void 0) continue;
772
+ const at = `input.controls.${name}.${zone}.${id}`;
773
+ for (const action of slot.buttons) if (!actions.has(action)) fail(`${at}.buttons`, `names no declared action "${action}"`);
774
+ if (typeof slot.as !== "object") continue;
775
+ for (const action of Object.keys(slot.as)) if (!slot.buttons.includes(action)) fail(`${at}.as`, `names no button "${action}" in this control`);
776
+ }
777
+ for (const [id, achievement] of Object.entries(declaredAchievements(manifest))) {
778
+ const progress = achievement.progress;
779
+ if (progress?.tiers === void 0) continue;
780
+ const way = achievementDirection(achievement);
781
+ let previous = null;
782
+ for (const [index, tier] of progress.tiers.entries()) {
783
+ const at = `achievements.list.${id}.progress.tiers[${index}].at`;
784
+ if (previous !== null && !beatsScore(way, tier.at, previous)) fail(at, `must be past ${previous}, which is the tier before it`);
785
+ if (beatsScore(way, tier.at, progress.target)) fail(at, `must be short of the target of ${progress.target}`);
786
+ previous = tier.at;
787
+ }
788
+ }
789
+ }
790
+ /**
791
+ * Whether `a` beats `b` on a board sorted this way. Ties are not improvements.
792
+ *
793
+ * @group Score
794
+ */
795
+ function beatsScore(direction, a, b) {
796
+ return direction === "asc" ? a < b : a > b;
797
+ }
798
+ /**
799
+ * The achievements a game declared, with the default applied.
800
+ *
801
+ * An empty map for a game that declared nothing, which reads as "this game has
802
+ * no achievements" — the only safe reading of silence, and what makes the tab
803
+ * absent rather than empty.
804
+ *
805
+ * @group Achievements
806
+ */
807
+ function declaredAchievements(manifest) {
808
+ return manifest.achievements?.list ?? {};
809
+ }
810
+ /**
811
+ * Which way a measured achievement counts, with the default applied.
812
+ *
813
+ * One place that knows, because a host, a client and a server that disagreed
814
+ * would each be right about a different achievement.
815
+ *
816
+ * @group Achievements
817
+ */
818
+ function achievementDirection(achievement) {
819
+ return achievement.progress?.direction ?? "desc";
820
+ }
821
+ /**
822
+ * The layouts of on-screen controls a game declared.
823
+ *
824
+ * An empty map for a game that declared none, which reads as "draw nothing over
825
+ * this game" — the only safe reading of silence, and what keeps a pad off a
826
+ * game written before any of this existed.
827
+ *
828
+ * @group Input
829
+ */
830
+ function controlLayouts(manifest) {
831
+ return manifest.input?.controls ?? {};
832
+ }
833
+ /**
834
+ * Every action a layout puts on the screen, in the order the zones declare them.
835
+ *
836
+ * @group Input
837
+ */
838
+ function layoutActions(layout) {
839
+ const found = [];
840
+ for (const zone of CONTROL_ZONES) {
841
+ const slots = layout[zone];
842
+ if (slots === void 0) continue;
843
+ for (const slot of Object.values(slots)) {
844
+ if (slot.type !== "buttons" || !Array.isArray(slot.buttons)) continue;
845
+ for (const action of slot.buttons) if (!found.includes(action)) found.push(action);
846
+ }
847
+ }
848
+ return found;
849
+ }
850
+ //#endregion
851
+ export { describeProblems, validateManifest };