@hopple/sdk 0.1.0-rc.3 → 1.0.0-next.12

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