@seekprotocol/sdk 0.0.0-stage → 0.1.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/LICENSE.md +77 -0
- package/README.md +29 -2
- package/assets.d.ts +109 -0
- package/builders.d.ts +74 -0
- package/compile.d.ts +27 -0
- package/games.d.ts +99 -0
- package/index.d.ts +10 -0
- package/index.js +5 -0
- package/log.d.ts +37 -0
- package/package.d.ts +41 -0
- package/package.json +25 -4
- package/sign.d.ts +38 -0
- package/types.d.ts +253 -0
- package/validate.d.ts +75 -0
- package/verify.d.ts +12 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Seek Protocol SDK License
|
|
2
|
+
|
|
3
|
+
**Draft of 10 November 2026. A lawyer has not reviewed this text yet.**
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2026 Seek Protocol (https://www.seekprotocol.ai/en). All rights
|
|
6
|
+
reserved.
|
|
7
|
+
|
|
8
|
+
This license covers the npm packages `@seekprotocol/sdk` and
|
|
9
|
+
`@seekprotocol/cli` (the "Software"), including the engine the CLI carries.
|
|
10
|
+
The GameKit bridge, `@seekprotocol/game`, has its own MIT license.
|
|
11
|
+
|
|
12
|
+
## 1. What you may do
|
|
13
|
+
|
|
14
|
+
Seek Protocol ("Seek") gives you a free, worldwide, non-exclusive,
|
|
15
|
+
non-transferable license to install and use the Software, on as many computers
|
|
16
|
+
as you like, to build, test, sign and submit quests and minigames for Seek, and
|
|
17
|
+
to run them in the Seek simulator.
|
|
18
|
+
|
|
19
|
+
You may keep the Software in your own projects and repositories, and install it
|
|
20
|
+
in your build and CI systems, as long as it is installed from npm and not
|
|
21
|
+
changed.
|
|
22
|
+
|
|
23
|
+
## 2. What you may not do
|
|
24
|
+
|
|
25
|
+
You may not:
|
|
26
|
+
|
|
27
|
+
- use the Software, or any part of it, to build or run quests, games or a
|
|
28
|
+
platform for anything other than Seek;
|
|
29
|
+
- copy, change, translate or redistribute the Software, other than installing
|
|
30
|
+
it as described above;
|
|
31
|
+
- decompile, de-minify or reverse engineer the Software, or try to recover its
|
|
32
|
+
source code, except where the law gives you that right and it cannot be
|
|
33
|
+
excluded;
|
|
34
|
+
- remove or change this license or Seek's copyright notices;
|
|
35
|
+
- use the Software to get around Store Connect, App Review or any limit Seek
|
|
36
|
+
sets.
|
|
37
|
+
|
|
38
|
+
## 3. What you make
|
|
39
|
+
|
|
40
|
+
What you make with the Software is yours. Submitting a quest or minigame to
|
|
41
|
+
Seek, and what Seek may do with it, falls under the Seek Maker Agreement, which
|
|
42
|
+
you accept in Store Connect.
|
|
43
|
+
|
|
44
|
+
## 4. Updates and changes
|
|
45
|
+
|
|
46
|
+
Seek may publish new versions of the Software and may change this license for
|
|
47
|
+
new versions. A version you installed stays under the license it came with.
|
|
48
|
+
Store Connect may refuse bundles built with versions Seek no longer supports.
|
|
49
|
+
|
|
50
|
+
## 5. No warranty
|
|
51
|
+
|
|
52
|
+
The Software is provided "as is", without warranty of any kind, express or
|
|
53
|
+
implied, including the warranties of merchantability, fitness for a particular
|
|
54
|
+
purpose and non-infringement.
|
|
55
|
+
|
|
56
|
+
## 6. Liability
|
|
57
|
+
|
|
58
|
+
Seek is not liable for indirect loss or lost profit arising from the Software
|
|
59
|
+
or its use. Seek's total liability under this license is limited to EUR 500,
|
|
60
|
+
except where the law does not allow a limit. Where you have also accepted the
|
|
61
|
+
Seek Maker Agreement, its liability section applies instead.
|
|
62
|
+
|
|
63
|
+
## 7. End
|
|
64
|
+
|
|
65
|
+
This license ends automatically when you break it. You then stop using the
|
|
66
|
+
Software and remove it from your systems.
|
|
67
|
+
|
|
68
|
+
## 8. Third-party software
|
|
69
|
+
|
|
70
|
+
The Software includes open-source code from others, under their own licenses.
|
|
71
|
+
They are listed, with their license texts, in `THIRD_PARTY_NOTICES.md` in the
|
|
72
|
+
package.
|
|
73
|
+
|
|
74
|
+
## 9. Law
|
|
75
|
+
|
|
76
|
+
Dutch law applies. Disputes go to the competent court in the Netherlands,
|
|
77
|
+
unless the law of your country gives you a right to go to your own court.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @seekprotocol/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write quests for the Seek app in TypeScript. Made by
|
|
4
|
+
[Seek Protocol](https://www.seekprotocol.ai/en).
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { quest, step, zone, reward, t } from '@seekprotocol/sdk';
|
|
8
|
+
|
|
9
|
+
const bridge = zone.place('Erasmusbrug', { lat: 51.9094, lng: 4.4868 });
|
|
10
|
+
|
|
11
|
+
export default quest({
|
|
12
|
+
id: 'harbour-walk',
|
|
13
|
+
title: t('Harbour walk'),
|
|
14
|
+
area: zone.city('Rotterdam', { lat: 51.9225, lng: 4.4792 }, 8),
|
|
15
|
+
capabilities: ['location.markers', 'catch', 'spawns.place'],
|
|
16
|
+
declarations: { places: 'public', effort: 'light', hazards: false, ai: 'none', sponsored: false, gameEvents: false, rights: true },
|
|
17
|
+
steps: [
|
|
18
|
+
step.talk({ id: 'captain', giver: 'harbour-master', at: bridge, title: t('Meet the harbour master') }),
|
|
19
|
+
step.catch({ id: 'cargo', coin: 'SEEK', count: 3, near: bridge, title: t('Catch 3 SEEK near the bridge') }),
|
|
20
|
+
],
|
|
21
|
+
rewards: [reward.xp(300)],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A quest is data: the SDK describes it, and Seek's server runs it. Test and
|
|
26
|
+
submit it with [`@seekprotocol/cli`](https://www.npmjs.com/package/@seekprotocol/cli).
|
|
27
|
+
|
|
28
|
+
Seek Protocol: https://www.seekprotocol.ai/en
|
|
29
|
+
|
|
30
|
+
Copyright (c) 2026 Seek Protocol. Licensed under the Seek Protocol SDK License, see `LICENSE.md`.
|
package/assets.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a minigame's files cost a phone: pixels in memory, seconds of sound,
|
|
3
|
+
* and the bytes it loads before it can start.
|
|
4
|
+
*
|
|
5
|
+
* Megabytes say little about memory: a 5 MB PNG can be 8000 by 8000 pixels,
|
|
6
|
+
* and once decoded that is 256 MB, which ends a WebView on an older phone
|
|
7
|
+
* without a word. So images are measured by their pixels, read from the file
|
|
8
|
+
* header without decoding anything, and sound by its length.
|
|
9
|
+
*
|
|
10
|
+
* Two levels, like the rest of Seek's checks: an `issue` means the game cannot
|
|
11
|
+
* be submitted (`seek build`, Store Connect and the review all refuse it), an
|
|
12
|
+
* `advice` is a tip that `seek test` prints and `seek optimize` can act on.
|
|
13
|
+
* The same file runs in the CLI, the Workers and the Deno functions, so a
|
|
14
|
+
* maker sees on their own laptop exactly what Seek will say.
|
|
15
|
+
*
|
|
16
|
+
* Plain JavaScript on bytes: no Node APIs, no DOM.
|
|
17
|
+
*/
|
|
18
|
+
import { type GameIssue } from './games.js';
|
|
19
|
+
export declare const ASSET_LIMITS: {
|
|
20
|
+
/** Longest side of an image. Above it the game is refused: phones cannot keep such a texture. */
|
|
21
|
+
readonly imageMaxSide: 4096;
|
|
22
|
+
/** Longest side `seek optimize` scales down to, and above which `seek test` advises it. */
|
|
23
|
+
readonly imageAdviseSide: 2048;
|
|
24
|
+
/** All images of a game decoded (width x height x 4 bytes), refused above this. */
|
|
25
|
+
readonly decodedMaxBytes: number;
|
|
26
|
+
readonly decodedAdviseBytes: number;
|
|
27
|
+
/** What index.html loads before the game can start (its scripts, styles, images and their imports). */
|
|
28
|
+
readonly startMaxBytes: number;
|
|
29
|
+
readonly startAdviseBytes: number;
|
|
30
|
+
/** A WAV file: uncompressed, so large ones are refused and any of size gets a tip. */
|
|
31
|
+
readonly wavMaxBytes: number;
|
|
32
|
+
readonly wavAdviseBytes: number;
|
|
33
|
+
/** One sound, in seconds. */
|
|
34
|
+
readonly audioMaxSeconds: 600;
|
|
35
|
+
/** A PNG without transparency above this is probably a photo: JPEG or WebP is far smaller. */
|
|
36
|
+
readonly opaquePngAdviseBytes: number;
|
|
37
|
+
/** A GIF above this: animated GIFs are heavy, WebP or a sprite sheet is lighter. */
|
|
38
|
+
readonly gifAdviseBytes: number;
|
|
39
|
+
};
|
|
40
|
+
/** The store cover of a quest: what Store Connect accepts, and what the app shows at 16:9. */
|
|
41
|
+
export declare const COVER_SPEC: {
|
|
42
|
+
readonly maxBytes: number;
|
|
43
|
+
readonly minWidth: 1280;
|
|
44
|
+
readonly minHeight: 720;
|
|
45
|
+
readonly maxWidth: 3840;
|
|
46
|
+
readonly maxHeight: 2160;
|
|
47
|
+
/** Width divided by height, within 1%. */
|
|
48
|
+
readonly ratio: number;
|
|
49
|
+
readonly ratioTolerance: 0.01;
|
|
50
|
+
readonly types: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
export interface ImageSize {
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
/** PNG only: whether the image has an alpha channel (colour type 4 or 6, or a tRNS chunk). */
|
|
56
|
+
alpha?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** An image's pixel size from its header, or null when it cannot be read (SVG has none: it is drawn, not decoded). */
|
|
59
|
+
export declare function imageSize(path: string, bytes: Uint8Array): ImageSize | null;
|
|
60
|
+
/** How long a sound file plays, in seconds, from its headers; null when that cannot be told. */
|
|
61
|
+
export declare function audioSeconds(path: string, bytes: Uint8Array): number | null;
|
|
62
|
+
/**
|
|
63
|
+
* The files index.html needs before the game can call ready(): what it names
|
|
64
|
+
* in src and href (links excepted), what those stylesheets import and use, and
|
|
65
|
+
* the static imports of its scripts, followed through. Dynamic import() and
|
|
66
|
+
* fetch() are not followed: that is how a game loads the rest later.
|
|
67
|
+
*/
|
|
68
|
+
export declare function startFiles(files: ReadonlyMap<string, Uint8Array>): string[];
|
|
69
|
+
export interface GameAdvice {
|
|
70
|
+
path: string;
|
|
71
|
+
code: string;
|
|
72
|
+
message: string;
|
|
73
|
+
}
|
|
74
|
+
export interface GameAssetReport {
|
|
75
|
+
images: {
|
|
76
|
+
path: string;
|
|
77
|
+
width: number;
|
|
78
|
+
height: number;
|
|
79
|
+
bytes: number;
|
|
80
|
+
decoded: number;
|
|
81
|
+
alpha?: boolean;
|
|
82
|
+
}[];
|
|
83
|
+
sounds: {
|
|
84
|
+
path: string;
|
|
85
|
+
seconds: number | null;
|
|
86
|
+
bytes: number;
|
|
87
|
+
}[];
|
|
88
|
+
/** All images decoded, in bytes. */
|
|
89
|
+
decodedBytes: number;
|
|
90
|
+
/** What index.html loads before ready(). */
|
|
91
|
+
start: {
|
|
92
|
+
files: string[];
|
|
93
|
+
bytes: number;
|
|
94
|
+
};
|
|
95
|
+
/** The game cannot be submitted with these. */
|
|
96
|
+
issues: GameIssue[];
|
|
97
|
+
/** Tips: `seek test` prints them, `seek optimize` acts on most. */
|
|
98
|
+
advice: GameAdvice[];
|
|
99
|
+
}
|
|
100
|
+
/** Everything this file knows about a game's files, as issues and advice. */
|
|
101
|
+
export declare function analyzeGameAssets(input: Iterable<{
|
|
102
|
+
path: string;
|
|
103
|
+
bytes: Uint8Array;
|
|
104
|
+
}> | ReadonlyMap<string, Uint8Array>): GameAssetReport;
|
|
105
|
+
/** Why a cover cannot be used, or null. Checked by its bytes, never by its name. */
|
|
106
|
+
export declare function coverProblem(bytes: Uint8Array): {
|
|
107
|
+
code: string;
|
|
108
|
+
message: string;
|
|
109
|
+
} | null;
|
package/builders.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a maker writes a quest in.
|
|
3
|
+
*
|
|
4
|
+
* import { quest, step, zone, reward, t } from '@seekprotocol/sdk';
|
|
5
|
+
*
|
|
6
|
+
* Every builder returns plain data. Nothing here runs later: `seek build`
|
|
7
|
+
* calls `compile` on what the quest file exports, and the JSON that comes out
|
|
8
|
+
* is what Seek reviews and runs.
|
|
9
|
+
*/
|
|
10
|
+
import type { Area, ArenaStep, ArriveStep, CatchStep, ChoiceStep, Place, PlayStep, Point, QuestDefinition, Reward, TalkStep, Text, WalkStep } from './types.js';
|
|
11
|
+
/** Player-facing text, in English. Seek translates it into the other 30 languages. */
|
|
12
|
+
export declare function t(en: string): Text;
|
|
13
|
+
/** The quest itself. Returned unchanged; the type checks it as you write. */
|
|
14
|
+
export declare function quest(definition: QuestDefinition): QuestDefinition;
|
|
15
|
+
export declare const zone: {
|
|
16
|
+
/** Playable anywhere a player is. */
|
|
17
|
+
anywhere(): Area;
|
|
18
|
+
/** A city or neighbourhood, as a centre and a radius. */
|
|
19
|
+
circle(name: string, center: Point, radiusM: number): Area;
|
|
20
|
+
/** A city, with its centre and a radius in kilometres (10 by default). */
|
|
21
|
+
city(name: string, center: Point, radiusKm?: number): Area;
|
|
22
|
+
/** A place a step points at: a landmark, a square, a park. */
|
|
23
|
+
place(name: string, at: Point): Place;
|
|
24
|
+
};
|
|
25
|
+
type Common = {
|
|
26
|
+
id: string;
|
|
27
|
+
title: Text;
|
|
28
|
+
story?: Text;
|
|
29
|
+
next?: string | null;
|
|
30
|
+
};
|
|
31
|
+
export declare const step: {
|
|
32
|
+
talk(s: Common & {
|
|
33
|
+
giver: string;
|
|
34
|
+
at?: Place;
|
|
35
|
+
}): TalkStep;
|
|
36
|
+
arrive(s: Common & {
|
|
37
|
+
at: Place;
|
|
38
|
+
radiusM?: number;
|
|
39
|
+
}): ArriveStep;
|
|
40
|
+
catch(s: Common & {
|
|
41
|
+
coin?: string;
|
|
42
|
+
count: number;
|
|
43
|
+
near?: Place;
|
|
44
|
+
withinM?: number;
|
|
45
|
+
}): CatchStep;
|
|
46
|
+
walk(s: Common & {
|
|
47
|
+
steps: number;
|
|
48
|
+
verified?: boolean;
|
|
49
|
+
}): WalkStep;
|
|
50
|
+
arena(s: Common & {
|
|
51
|
+
wins?: number;
|
|
52
|
+
}): ArenaStep;
|
|
53
|
+
choice(s: Common & {
|
|
54
|
+
options: {
|
|
55
|
+
id: string;
|
|
56
|
+
label: Text;
|
|
57
|
+
next: string | null;
|
|
58
|
+
}[];
|
|
59
|
+
}): ChoiceStep;
|
|
60
|
+
play(s: Common & {
|
|
61
|
+
game: string;
|
|
62
|
+
minScore: number;
|
|
63
|
+
maxScore: number;
|
|
64
|
+
}): PlayStep;
|
|
65
|
+
};
|
|
66
|
+
export declare const reward: {
|
|
67
|
+
xp(amount: number): Reward;
|
|
68
|
+
card(card: string): Reward;
|
|
69
|
+
cosmetic(item: string): Reward;
|
|
70
|
+
boost(boost: string, count?: number): Reward;
|
|
71
|
+
/** A coin pot. Needs the `rewards.crypto` capability, which only Seek grants. */
|
|
72
|
+
pool(coin: string, perPlayer: number, maxPlayers: number): Reward;
|
|
73
|
+
};
|
|
74
|
+
export {};
|
package/compile.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* From a quest definition to the bundle Seek runs.
|
|
3
|
+
*
|
|
4
|
+
* Texts move into one table keyed by where they sit (`steps.bridge.title`),
|
|
5
|
+
* which is what Seek translates; every `next` is resolved, so the server never
|
|
6
|
+
* has to know the order steps were written in. The output is canonical JSON,
|
|
7
|
+
* and its SHA-256 is the bundle's identity everywhere after this: in R2, in
|
|
8
|
+
* the version row, in the pointer that makes it live.
|
|
9
|
+
*/
|
|
10
|
+
import { type QuestBundle, type QuestDefinition } from './types.js';
|
|
11
|
+
import { type Issue } from './validate.js';
|
|
12
|
+
export declare const VERSION_PATTERN: RegExp;
|
|
13
|
+
export declare class QuestInvalidError extends Error {
|
|
14
|
+
readonly issues: Issue[];
|
|
15
|
+
constructor(issues: Issue[]);
|
|
16
|
+
}
|
|
17
|
+
export interface CompileOptions {
|
|
18
|
+
version: string;
|
|
19
|
+
build: number;
|
|
20
|
+
/** GameKit: the hash of each `play` step's game, from `packageGame`. Written to `manifest.games`. */
|
|
21
|
+
games?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
export declare function compile(q: QuestDefinition, opts: CompileOptions): QuestBundle;
|
|
24
|
+
/** JSON with sorted keys and no whitespace: the same bundle always gives the same bytes. */
|
|
25
|
+
export declare function canonicalJson(value: unknown): string;
|
|
26
|
+
/** The bundle's identity: SHA-256 of its canonical JSON, in hex. */
|
|
27
|
+
export declare function bundleHash(bundle: QuestBundle): Promise<string>;
|
package/games.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { GAME_ID_PATTERN } from './types.js';
|
|
2
|
+
import { type GameAssetReport } from './assets.js';
|
|
3
|
+
export { GAME_ID_PATTERN };
|
|
4
|
+
export declare const GAME_FORMAT: 1;
|
|
5
|
+
/** The id a game has on Seek's CDN and in launch tokens: `<gameId>-<16 hex of sha256(questId)>`. */
|
|
6
|
+
export declare const GAME_APP_ID_PATTERN: RegExp;
|
|
7
|
+
/** Where games are served from: `<appId>.play.seekprotocol.ai`. */
|
|
8
|
+
export declare const GAME_HOST_SUFFIX = "play.seekprotocol.ai";
|
|
9
|
+
/** The only origin a game may reach over the network. */
|
|
10
|
+
export declare const GAME_PROXY_ORIGIN = "https://api.play.seekprotocol.ai";
|
|
11
|
+
export declare const GAME_LIMITS: {
|
|
12
|
+
readonly files: 200;
|
|
13
|
+
readonly fileBytes: number;
|
|
14
|
+
readonly totalBytes: number;
|
|
15
|
+
readonly pathLength: 120;
|
|
16
|
+
readonly depth: 5;
|
|
17
|
+
/** One path segment: no hidden files, no `..`, nothing a shell or a URL treats specially. */
|
|
18
|
+
readonly segment: RegExp;
|
|
19
|
+
/** Per player per game, through the proxy. */
|
|
20
|
+
readonly storageBytes: number;
|
|
21
|
+
};
|
|
22
|
+
export type GameFileKind = 'html' | 'script' | 'style' | 'data' | 'image' | 'audio' | 'font';
|
|
23
|
+
/** Every file type a game may carry, by extension. Anything else is refused. */
|
|
24
|
+
export declare const GAME_FILE_TYPES: Record<string, {
|
|
25
|
+
type: string;
|
|
26
|
+
kind: GameFileKind;
|
|
27
|
+
text: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
export declare const GAME_ENTRY = "index.html";
|
|
30
|
+
export interface GameFile {
|
|
31
|
+
path: string;
|
|
32
|
+
size: number;
|
|
33
|
+
/** SHA-256 of the bytes, hex. */
|
|
34
|
+
sha256: string;
|
|
35
|
+
/** The Content-Type it is served with, from `GAME_FILE_TYPES`. */
|
|
36
|
+
type: string;
|
|
37
|
+
}
|
|
38
|
+
/** What `seek build` makes of `games/<id>/`, and what its hash is taken over. */
|
|
39
|
+
export interface GameManifest {
|
|
40
|
+
format: typeof GAME_FORMAT;
|
|
41
|
+
id: string;
|
|
42
|
+
entry: typeof GAME_ENTRY;
|
|
43
|
+
/** Sorted by path. */
|
|
44
|
+
files: GameFile[];
|
|
45
|
+
/** All files together, in bytes. */
|
|
46
|
+
size: number;
|
|
47
|
+
}
|
|
48
|
+
export interface GameIssue {
|
|
49
|
+
path: string;
|
|
50
|
+
code: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
export declare const extensionOf: (path: string) => string;
|
|
54
|
+
export declare const gameFileType: (path: string) => {
|
|
55
|
+
type: string;
|
|
56
|
+
kind: GameFileKind;
|
|
57
|
+
text: boolean;
|
|
58
|
+
} | undefined;
|
|
59
|
+
/** Why a path cannot be in a game, or null when it can. */
|
|
60
|
+
export declare function gamePathProblem(path: string): GameIssue | null;
|
|
61
|
+
/** Whether the bytes are what the extension claims: text is UTF-8 without NUL, binaries carry their signature, nothing is an executable. */
|
|
62
|
+
export declare function sniffGameFile(path: string, bytes: Uint8Array): GameIssue | null;
|
|
63
|
+
/** Everything about a manifest that can be checked without the bytes. */
|
|
64
|
+
export declare function validateGameManifest(m: unknown): GameIssue[];
|
|
65
|
+
/** The game's identity: SHA-256 of its manifest's canonical JSON, hex. */
|
|
66
|
+
export declare const gameHash: (m: GameManifest) => Promise<string>;
|
|
67
|
+
/** Whether one file's bytes are what the manifest says. */
|
|
68
|
+
export declare function verifyGameFile(f: GameFile, bytes: Uint8Array): Promise<GameIssue | null>;
|
|
69
|
+
export interface PackagedGame {
|
|
70
|
+
manifest: GameManifest;
|
|
71
|
+
hash: string;
|
|
72
|
+
issues: GameIssue[];
|
|
73
|
+
/** Pixels, sound and what loads before ready(): its issues are in `issues` too. */
|
|
74
|
+
assets: GameAssetReport;
|
|
75
|
+
}
|
|
76
|
+
/** From the files of `games/<id>/` to a manifest and a hash. Issues mean the game cannot be submitted. */
|
|
77
|
+
export declare function packageGame(id: string, files: {
|
|
78
|
+
path: string;
|
|
79
|
+
bytes: Uint8Array;
|
|
80
|
+
}[]): Promise<PackagedGame>;
|
|
81
|
+
/** The id a quest's game has on Seek's CDN: unique per quest, so two makers' `runner` never share a domain or a `sub`. */
|
|
82
|
+
export declare function gameAppId(questId: string, gameId: string): Promise<string>;
|
|
83
|
+
export declare const gameOrigin: (appId: string) => string;
|
|
84
|
+
export interface GameFinding {
|
|
85
|
+
path: string;
|
|
86
|
+
line: number;
|
|
87
|
+
code: string;
|
|
88
|
+
/** `fail`: the version cannot pass. `flag`: a person looks at it. */
|
|
89
|
+
severity: 'fail' | 'flag';
|
|
90
|
+
message: string;
|
|
91
|
+
match: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* What a reviewer should know about one text file of a game. Remote URLs that
|
|
95
|
+
* would load something fail (the CSP blocks them anyway, but a game that
|
|
96
|
+
* relies on them is broken or trying something); remote URLs in plain text,
|
|
97
|
+
* inline handlers and eval-like code are flagged for a person.
|
|
98
|
+
*/
|
|
99
|
+
export declare function lintGameText(path: string, text: string, limit?: number): GameFinding[];
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './builders.js';
|
|
3
|
+
export * from './validate.js';
|
|
4
|
+
export * from './compile.js';
|
|
5
|
+
export * from './sign.js';
|
|
6
|
+
export * from './log.js';
|
|
7
|
+
export * from './package.js';
|
|
8
|
+
export * from './verify.js';
|
|
9
|
+
export * from './games.js';
|
|
10
|
+
export * from './assets.js';
|
package/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
var F=1,W=1,$=/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/,Q=["location.markers","spawns.place","catch","steps","arena","rewards.cards","rewards.cosmetics","rewards.boosts","rewards.crypto","gamekit"],U={places:["public","private_with_permission"],effort:["light","moderate","hard"],ai:["none","assisted","generated"]};function tt(e){return{en:e}}function nt(e){return e}var st={anywhere(){return{kind:"anywhere"}},circle(e,t,n){return{kind:"circle",name:e,center:t,radiusM:n}},city(e,t,n=10){return{kind:"circle",name:e,center:t,radiusM:Math.round(n*1e3)}},place(e,t){return{name:e,at:t}}},it={talk(e){return{kind:"talk",...e}},arrive(e){return{kind:"arrive",radiusM:50,...e}},catch(e){return{kind:"catch",coin:"any",...e}},walk(e){return{kind:"walk",verified:!1,...e}},arena(e){return{kind:"arena",wins:1,...e}},choice(e){return{kind:"choice",...e}},play(e){return{kind:"play",...e}}},at={xp(e){return{kind:"xp",amount:e}},card(e){return{kind:"card",card:e}},cosmetic(e){return{kind:"cosmetic",item:e}},boost(e,t=1){return{kind:"boost",boost:e,count:t}},pool(e,t,n){return{kind:"pool",coin:e,perPlayer:t,maxPlayers:n}}};var m={steps:{min:1,max:30},idPattern:/^[a-z0-9][a-z0-9-]{2,39}$/,stepIdPattern:/^[a-z0-9][a-z0-9-]{0,39}$/,title:60,summary:160,story:600,areaRadiusM:{min:100,max:5e4},arriveRadiusM:{min:25,max:500},catchCount:{min:1,max:50},catchWithinM:{min:50,max:2e3},walkSteps:{min:100,max:5e4},arenaWins:{min:1,max:20},choiceOptions:{min:2,max:4},xp:{max:5e3},boostCount:{max:3},rewards:{max:6},windowDays:{max:92}},oe={talk:["location.markers"],arrive:["location.markers"],catch:["catch"],walk:["steps"],arena:["arena"],choice:[],play:["gamekit"]};function ce(e,t){let s=(t.lat-e.lat)*Math.PI/180,a=(t.lng-e.lng)*Math.PI/180,o=Math.sin(s/2)**2+Math.cos(e.lat*Math.PI/180)*Math.cos(t.lat*Math.PI/180)*Math.sin(a/2)**2;return 2*6371e3*Math.asin(Math.min(1,Math.sqrt(o)))}var C=e=>!!e&&Number.isFinite(e.lat)&&Number.isFinite(e.lng)&&Math.abs(e.lat)<=85&&Math.abs(e.lng)<=180;function de(e){let t=new Set;for(let n of e.steps){for(let s of oe[n.kind])t.add(s);n.kind==="catch"&&n.near&&t.add("spawns.place")}for(let n of e.rewards)n.kind==="card"&&t.add("rewards.cards"),n.kind==="cosmetic"&&t.add("rewards.cosmetics"),n.kind==="boost"&&t.add("rewards.boosts"),n.kind==="pool"&&t.add("rewards.crypto");return t}function z(e,t){let n=e[t];return n.kind==="choice"?n.options.map(s=>s.next):n.next!==void 0?[n.next]:[e[t+1]?.id??null]}function K(e){let t=[],n=(i,c,l)=>t.push({path:i,code:c,message:l}),s=(i,c,l,f)=>{(!Number.isFinite(c)||c<(l.min??0)||c>l.max)&&n(i,"out_of_range",`${f} must be between ${l.min??0} and ${l.max}.`)},a=(i,c,l,f)=>{if(!c||typeof c.en!="string"||!c.en.trim()){f&&n(i,"text_missing","Text is required.");return}c.en.length>l&&n(i,"text_too_long",`At most ${l} characters (${c.en.length}).`)};m.idPattern.test(e.id??"")||n("id","bad_id","Use 3 to 40 lowercase letters, digits and hyphens."),a("title",e.title,m.title,!0),a("summary",e.summary,m.summary,!1),e.area?.kind==="circle"?(C(e.area.center)||n("area.center","bad_point","Not a valid coordinate."),s("area.radiusM",e.area.radiusM,m.areaRadiusM,"The area radius")):e.area?.kind!=="anywhere"&&n("area","bad_area","Use zone.anywhere(), zone.city() or zone.circle().");let o=(i,c)=>{if(!C(c))return n(i,"bad_point","Not a valid coordinate.");e.area?.kind==="circle"&&C(e.area.center)&&ce(e.area.center,c)>e.area.radiusM&&n(i,"outside_area",`This place is outside the quest area (${e.area.name}).`)};if(e.window){let i=Date.parse(e.window.from),c=Date.parse(e.window.to);!Number.isFinite(i)||!Number.isFinite(c)||c<=i?n("window","bad_window","The window needs a start before its end."):c-i>m.windowDays.max*864e5&&n("window","window_too_long",`A window lasts at most ${m.windowDays.max} days.`)}for(let[i,c]of(e.capabilities??[]).entries())Q.includes(c)||n(`capabilities[${i}]`,"unknown_capability",`Unknown capability "${c}".`);for(let i of de(e))e.capabilities?.includes(i)||n("capabilities","capability_missing",`The quest uses "${i}" but does not declare it.`);let u=e.steps??[];(u.length<m.steps.min||u.length>m.steps.max)&&n("steps","step_count",`A quest has ${m.steps.min} to ${m.steps.max} steps.`);let d=new Set;if(u.forEach((i,c)=>{let l=`steps[${c}]`;switch(m.stepIdPattern.test(i.id??"")||n(`${l}.id`,"bad_id","Use lowercase letters, digits and hyphens."),d.has(i.id)&&n(`${l}.id`,"duplicate_id",`Step id "${i.id}" is used twice.`),d.add(i.id),a(`${l}.title`,i.title,m.title,!0),a(`${l}.story`,i.story,m.story,!1),i.kind){case"talk":i.giver||n(`${l}.giver`,"giver_missing","Name the quest-giver."),i.at?o(`${l}.at`,i.at.at):e.area?.kind!=="circle"&&n(`${l}.at`,"place_missing","A quest playable anywhere needs a place for its giver.");break;case"arrive":o(`${l}.at`,i.at?.at),s(`${l}.radiusM`,i.radiusM,m.arriveRadiusM,"The arrival radius");break;case"catch":s(`${l}.count`,i.count,m.catchCount,"The number of catches"),i.near&&(o(`${l}.near`,i.near.at),s(`${l}.withinM`,i.withinM??500,m.catchWithinM,"The catch radius"));break;case"walk":s(`${l}.steps`,i.steps,m.walkSteps,"The number of steps");break;case"arena":s(`${l}.wins`,i.wins,m.arenaWins,"The number of wins");break;case"choice":{let f=i.options?.length??0;(f<m.choiceOptions.min||f>m.choiceOptions.max)&&n(`${l}.options`,"option_count","A choice has 2 to 4 options.");let h=new Set;i.options?.forEach((g,D)=>{h.has(g.id)&&n(`${l}.options[${D}].id`,"duplicate_id",`Option "${g.id}" is used twice.`),h.add(g.id),a(`${l}.options[${D}].label`,g.label,40,!0)});break}case"play":i.game?$.test(i.game)||n(`${l}.game`,"bad_game_id","A game id is its folder under games/: 3 to 30 lowercase letters, digits and hyphens."):n(`${l}.game`,"game_missing","Name the minigame."),(!(i.maxScore>0)||!(i.minScore>0)||i.minScore>i.maxScore)&&n(`${l}.minScore`,"bad_score","Scores are positive and minScore is at most maxScore.");break;default:n(`${l}.kind`,"unknown_step",`Unknown step kind "${i.kind}".`)}}),u.forEach((i,c)=>{z(u,c).forEach(l=>{l!==null&&!d.has(l)&&n(`steps[${c}].next`,"unknown_next",`No step "${l}".`)})}),u.length&&!t.some(i=>i.code==="unknown_next"||i.code==="duplicate_id")){let i=new Map(u.map((h,g)=>[h.id,g])),c=new Map,l=!1,f=h=>{if(c.get(h)!=="done"){if(c.get(h)==="open"){l=!0;return}c.set(h,"open");for(let g of z(u,i.get(h)))g!==null&&f(g);c.set(h,"done")}};f(u[0].id),l&&n("steps","loop","Steps lead back to an earlier step. A quest has to end."),u.forEach((h,g)=>{c.has(h.id)||n(`steps[${g}]`,"unreachable",`Step "${h.id}" can never be reached.`)})}let r=e.rewards??[];r.length>m.rewards.max&&n("rewards","reward_count",`At most ${m.rewards.max} rewards.`),r.forEach((i,c)=>{let l=`rewards[${c}]`;i.kind==="xp"&&s(`${l}.amount`,i.amount,{min:1,max:m.xp.max},"XP"),i.kind==="boost"&&s(`${l}.count`,i.count,{min:1,max:m.boostCount.max},"The boost count"),i.kind==="pool"&&((!(i.perPlayer>0)||!(i.maxPlayers>0)||!Number.isInteger(i.maxPlayers))&&n(l,"bad_pool","A pool pays a positive amount to a whole number of players."),(e.minAge??18)<18&&n("minAge","pool_needs_adults","A quest that pays coins is for players of 18 and over."))}),r.some(i=>i.kind==="pool")&&e.repeat&&e.repeat!=="never"&&n("repeat","pool_repeats","A quest that pays coins cannot be repeated.");let p=e.declarations;if(!p||typeof p!="object")n("declarations","declarations_missing","Say what the quest asks of players: places, effort, hazards, AI, sponsoring and game events.");else{let i=l=>{U[l].includes(p[l])||n(`declarations.${l}`,"bad_declaration",`One of ${U[l].join(", ")}.`)};i("places"),i("effort"),i("ai");for(let l of["hazards","sponsored","gameEvents"])typeof p[l]!="boolean"&&n(`declarations.${l}`,"bad_declaration","true or false.");p.rights!==!0&&n("declarations.rights","rights_not_declared","Declare rights: true. Only use texts, images, sounds, fonts, characters, logos and code that are yours or that you have a licence for.");let c=(e.steps??[]).reduce((l,f)=>l+(f.kind==="walk"?f.steps:0),0);p.effort==="light"&&c>1e4&&n("declarations.effort","effort_understated",`The walk steps add up to ${c} steps; that is not light.`)}return t}var le=/^(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})$/,T=class extends Error{constructor(n){super(`The quest has ${n.length} problem(s):
|
|
2
|
+
${n.map(s=>` ${s.path}: ${s.message}`).join(`
|
|
3
|
+
`)}`);this.issues=n}};function ue(e,t){if(t===void 0)return[];let n=[];if(!t||typeof t!="object"||Array.isArray(t))return[{path:"games",code:"bad_games",message:"games maps a game id to its hash."}];let s=new Set(e.steps.flatMap(a=>a.kind==="play"?[a.game]:[]));for(let[a,o]of Object.entries(t))$.test(a)||n.push({path:`games.${a}`,code:"bad_game_id",message:"A game id is 3 to 30 lowercase letters, digits and hyphens."}),(typeof o!="string"||!/^[0-9a-f]{64}$/.test(o))&&n.push({path:`games.${a}`,code:"bad_game_hash",message:"A game hash is 64 lowercase hex characters."}),s.has(a)||n.push({path:`games.${a}`,code:"game_unused",message:`No play step uses the game "${a}".`});for(let a of s)a in t||n.push({path:"games",code:"game_missing",message:`The play step's game "${a}" is not in the quest. Put it in games/${a}/.`});return n}function J(e,t){let n=K(e);if(le.test(t.version)||n.push({path:"version",code:"bad_version",message:"Use a version like 1.2.0."}),(!Number.isInteger(t.build)||t.build<1)&&n.push({path:"build",code:"bad_build",message:"The build number is a whole number from 1."}),n.push(...ue(e,t.games)),n.length)throw new T(n);let s={},a=(u,d)=>{if(d)return s[u]=d.en.trim(),u},o=e.steps.map((u,d)=>{let r=`steps.${u.id}`,[p]=z(e.steps,d),{title:i,story:c,next:l,...f}=u,h={...f,title:a(`${r}.title`,i),...c?{story:a(`${r}.story`,c)}:{},next:u.kind==="choice"?null:p??null};return u.kind==="choice"&&(h.options=u.options.map(g=>({id:g.id,label:a(`${r}.options.${g.id}`,g.label),next:g.next}))),h});return{manifest:{format:1,runtime:1,id:e.id,version:t.version,build:t.build,title:a("title",e.title),...e.summary?{summary:a("summary",e.summary)}:{},area:e.area,...e.window?{window:e.window}:{},repeat:e.repeat??"never",minAge:e.minAge??18,declarations:{...e.declarations},capabilities:[...e.capabilities].sort(),rewards:e.rewards,start:e.steps[0].id,...t.games&&Object.keys(t.games).length?{games:{...t.games}}:{}},steps:o,texts:{en:s}}}function M(e){if(e===null||typeof e!="object"){if(typeof e=="number"&&!Number.isFinite(e))throw new Error("Not JSON: a number that is not finite.");return JSON.stringify(e)}return Array.isArray(e)?`[${e.map(M).join(",")}]`:`{${Object.entries(e).filter(([,n])=>n!==void 0).sort(([n],[s])=>n<s?-1:n>s?1:0).map(([n,s])=>`${JSON.stringify(n)}:${M(s)}`).join(",")}}`}var pe=e=>[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join("");async function G(e){let t=new TextEncoder().encode(M(e));return pe(await crypto.subtle.digest("SHA-256",t))}var H=(e,t)=>`seek-bundle-v1:${t}:${e}`,fe=e=>btoa(String.fromCharCode(...e)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,""),V=e=>{let t=atob(e.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-e.length%4)%4));return Uint8Array.from(t,n=>n.charCodeAt(0))};async function pt(){let e=await crypto.subtle.generateKey({name:"Ed25519"},!0,["sign","verify"]),t=await crypto.subtle.exportKey("jwk",e.publicKey),n=await crypto.subtle.exportKey("jwk",e.privateKey);return{keyId:await me(t),publicJwk:t,privateJwk:n}}async function me(e){let t=await crypto.subtle.digest("SHA-256",V(e.x));return[...new Uint8Array(t)].slice(0,8).map(n=>n.toString(16).padStart(2,"0")).join("")}async function ft(e,t,n,s){let a=await crypto.subtle.importKey("jwk",s,{name:"Ed25519"},!1,["sign"]),o=await crypto.subtle.sign({name:"Ed25519"},a,new TextEncoder().encode(H(e,t)));return{by:t,keyId:n,sig:fe(new Uint8Array(o))}}async function ge(e,t,n){try{let s=await crypto.subtle.importKey("jwk",n,{name:"Ed25519"},!1,["verify"]);return await crypto.subtle.verify({name:"Ed25519"},s,V(t.sig),new TextEncoder().encode(H(e,t.by)))}catch{return!1}}async function mt(e,t,n){return t.by!=="seek"||!Object.hasOwn(n,t.keyId)?!1:ge(e,t,n[t.keyId])}var he="0".repeat(64);async function xe(e){let t=["seek-log-v1",e.seq,e.prev_entry,e.event,e.quest_id,e.version_id,e.hash,e.seek_key_id,e.seek_sig].join(":"),n=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t));return[...new Uint8Array(n)].map(s=>s.toString(16).padStart(2,"0")).join("")}async function ht(e,t={size:0,head:he}){let{size:n,head:s}=t;for(let a of e){if(a.seq!==n+1)return{ok:!1,seq:a.seq,reason:"bad_sequence"};if(a.prev_entry!==s)return{ok:!1,seq:a.seq,reason:"bad_link"};if(await xe(a)!==a.entry_hash)return{ok:!1,seq:a.seq,reason:"bad_hash"};n=a.seq,s=a.entry_hash}return{ok:!0,size:n,head:s}}function ye(e){let t=e.texts?.en??{},n=o=>o===void 0?void 0:{en:t[o]??""},s=e.manifest,a=e.steps.map(o=>{let{title:u,story:d,next:r,...p}=o,i={...p,title:n(u),...d?{story:n(d)}:{},next:r};return i.kind==="choice"&&o.kind==="choice"&&(i.options=o.options.map(c=>({id:c.id,label:n(c.label),next:c.next}))),i});return{id:s.id,title:n(s.title),...s.summary?{summary:n(s.summary)}:{},area:s.area,...s.window?{window:s.window}:{},repeat:s.repeat,minAge:s.minAge,declarations:s.declarations,capabilities:s.capabilities,steps:a,rewards:s.rewards}}async function X(e,t){let n=(r,p)=>({ok:!1,issues:[{path:"",code:r,message:p}]}),s=e;if(!s||typeof s!="object"||!s.manifest||!Array.isArray(s.steps)||!s.texts?.en)return n("bad_bundle","Not a quest bundle.");if(s.manifest.format!==1)return n("bad_format",`Unknown bundle format ${String(s.manifest.format)}.`);let a,o;try{a=ye(s),o=J(a,{version:s.manifest.version,build:s.manifest.build,...s.manifest.games!==void 0?{games:s.manifest.games}:{}})}catch(r){return r instanceof T?{ok:!1,issues:r.issues}:n("bad_bundle","The bundle could not be read.")}let[u,d]=await Promise.all([G(s),G(o)]);return u!==d?n("not_compiled","The bundle is not what seek build makes. Build it again with the CLI."):t!==void 0&&t!==u?n("hash_mismatch","The bundle does not match its hash."):{ok:!0,hash:u,definition:a}}var we=".seek",L={fileBytes:64*1024*1024,unpackedBytes:128*1024*1024},w=class extends Error{},kt=e=>`${e.manifest.id}-${e.manifest.version}-${e.manifest.build}${we}`;async function Y(e,t){let n=e.getReader(),s=[],a=0;for(;;){let{done:d,value:r}=await n.read();if(d)break;if(a+=r.byteLength,a>t)throw await n.cancel(),new w("This .seek file unpacks to more than a build can be.");s.push(r)}let o=new Uint8Array(a),u=0;for(let d of s)o.set(d,u),u+=d.byteLength;return o}var Z=e=>new Blob([e]).stream();async function At(e){let t=new TextEncoder().encode(JSON.stringify({format:1,bundle:e.bundle,hash:e.hash,signature:e.signature,games:e.games})),n=Z(t).pipeThrough(new CompressionStream("gzip"));return await Y(n,L.fileBytes)}async function _t(e){if(e.byteLength>L.fileBytes)throw new w("This .seek file is too big.");if(e[0]!==31||e[1]!==139)throw new w("This is not a .seek file. Make one with seek build.");let t;try{let o=Z(e).pipeThrough(new DecompressionStream("gzip"));t=JSON.parse(new TextDecoder().decode(await Y(o,L.unpackedBytes)))}catch(o){throw o instanceof w?o:new w("This .seek file is damaged. Build it again.")}if(!t||t.format!==1)throw new w("This .seek file was made by another version of seek. Build it again.");let n=await X(t.bundle,t.hash);if(!n.ok)throw new w(`This .seek file is damaged: ${n.issues[0].message}`);let s=t.bundle.manifest.games??{},a=t.games??{};for(let[o,u]of Object.entries(s))if(!Object.hasOwn(a,o)||a[o].hash!==u)throw new w(`This .seek file is damaged: the game "${o}" is not the one the quest names.`);if(t.signature&&(t.signature.by!=="maker"||typeof t.signature.keyId!="string"||typeof t.signature.sig!="string"))throw new w("This .seek file has a signature that is not a maker's.");return{format:1,bundle:t.bundle,hash:t.hash,signature:t.signature??null,games:a}}var be={imageMaxSide:4096,imageAdviseSide:2048,decodedMaxBytes:96*1024*1024,decodedAdviseBytes:48*1024*1024,startMaxBytes:8*1024*1024,startAdviseBytes:3*1024*1024,wavMaxBytes:2*1024*1024,wavAdviseBytes:256*1024,audioMaxSeconds:600,opaquePngAdviseBytes:300*1024,gifAdviseBytes:1024*1024},ke={maxBytes:1024*1024,minWidth:1280,minHeight:720,maxWidth:3840,maxHeight:2160,ratio:16/9,ratioTolerance:.01,types:["image/png","image/jpeg","image/webp"]},O=(e,t)=>(e[t]<<8|e[t+1])>>>0,I=(e,t)=>(e[t]|e[t+1]<<8)>>>0,q=(e,t)=>(e[t]|e[t+1]<<8|e[t+2]<<16)>>>0,b=(e,t)=>(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0,E=(e,t)=>(e[t]|e[t+1]<<8|e[t+2]<<16|e[t+3]<<24)>>>0,k=(e,t,n)=>String.fromCharCode(...e.subarray(t,t+n));function B(e,t,n=0,s=e.length){let a=[...t].map(o=>o.charCodeAt(0));e:for(let o=Math.max(0,n);o<=Math.min(e.length,s)-a.length;o++){for(let u=0;u<a.length;u++)if(e[o+u]!==a[u])continue e;return o}return-1}function Ae(e){if(e.length<33||k(e,12,4)!=="IHDR")return null;let t=e[25],n=B(e,"IDAT",33),s=B(e,"tRNS",33,n===-1?e.length:n);return{width:b(e,16),height:b(e,20),alpha:t===4||t===6||s!==-1}}function _e(e){let t=2;for(;t+9<e.length;){if(e[t]!==255){t++;continue}let n=e[t+1];if(n===255){t++;continue}if(n===216||n===1||n>=208&&n<=215){t+=2;continue}if(n===217||n===218)return null;if(n>=192&&n<=207&&n!==196&&n!==200&&n!==204)return{height:O(e,t+5),width:O(e,t+7)};t+=2+O(e,t+2)}return null}function Se(e){return e.length>=10?{width:I(e,6),height:I(e,8)}:null}function ve(e){if(e.length<30)return null;let t=k(e,12,4);if(t==="VP8 ")return e[23]!==157||e[24]!==1||e[25]!==42?null:{width:I(e,26)&16383,height:I(e,28)&16383};if(t==="VP8L"){if(e[20]!==47)return null;let n=e[21],s=e[22],a=e[23],o=e[24];return{width:1+((s&63)<<8|n),height:1+((o&15)<<10|a<<2|(s&192)>>6)}}return t==="VP8X"?{width:1+q(e,24),height:1+q(e,27)}:null}function $e(e){let t=null,n=Math.min(e.length,256*1024);for(let s=B(e,"ispe",0,n);s!==-1&&!(s+16>e.length);s=B(e,"ispe",s+4,n)){let a={width:b(e,s+8),height:b(e,s+12)};(!t||a.width*a.height>t.width*t.height)&&(t=a)}return t}function ne(e,t){switch(v(e)){case"png":return Ae(t);case"jpg":case"jpeg":return _e(t);case"gif":return Se(t);case"webp":return ve(t);case"avif":return $e(t);default:return null}}function Te(e){let t=0;for(let n=12;n+8<=e.length;){let s=k(e,n,4),a=E(e,n+4);if(s==="fmt "&&n+20<=e.length&&(t=E(e,n+16)),s==="data")return t>0?a/t:null;n+=8+a+a%2}return null}var ee={v1:[0,32,40,48,56,64,80,96,112,128,160,192,224,256,320],v2:[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160]},Me=[44100,48e3,32e3];function Pe(e){let t=0;for(k(e,0,3)==="ID3"&&e.length>10&&(t=10+(e[6]<<21|e[7]<<14|e[8]<<7|e[9]));t+4<e.length&&t<512*1024;t++){if(e[t]!==255||(e[t+1]&224)!==224)continue;let n=e[t+1]>>3&3,s=e[t+1]>>1&3,a=e[t+2]>>2&3,o=e[t+2]>>4;if(n===1||s!==1||a===3||o===0||o===15)continue;let u=n===3,d=Me[a]/(u?1:n===2?2:4),r=(u?ee.v1:ee.v2)[o],p=e[t+3]>>6===3,i=u?1152:576,c=t+4+(u?p?17:32:p?9:17),l=k(e,c,4);return(l==="Xing"||l==="Info")&&c+12<=e.length&&b(e,c+4)&1?b(e,c+8)*i/d:(e.length-t)*8/(r*1e3)}return null}function Ee(e){if(k(e,0,4)!=="OggS"||e.length<28)return null;let t=27+e[26],n=0,s=0;if(k(e,t,7)==="vorbis"?n=E(e,t+12):k(e,t,8)==="OpusHead"&&(n=48e3,s=I(e,t+10)),!n)return null;for(let a=e.length-14;a>=0;a--){if(e[a]!==79||k(e,a,4)!=="OggS")continue;let o=E(e,a+6)+E(e,a+10)*2**32;return Math.max(0,o-s)/n}return null}function Ie(e){let t=B(e,"mvhd");if(t===-1||t+32>e.length)return null;if(e[t+4]===1){let a=b(e,t+24),o=b(e,t+28)*2**32+b(e,t+32);return a?o/a:null}let s=b(e,t+16);return s?b(e,t+20)/s:null}function Be(e,t){switch(v(e)){case"wav":return Te(t);case"mp3":return Pe(t);case"ogg":return Ee(t);case"m4a":return Ie(t);default:return null}}var ze=/<(?!a[\s>])[a-z][^>]*?\s(?:src|href|poster)\s*=\s*["']([^"']+)["']/gi,te=/url\(\s*["']?([^"')]+)["']?\s*\)|@import\s+["']([^"']+)["']/gi,Re=/(?:^|[\s;}])(?:import|export)\s*(?:[\w$*{}\s,]+?\s*from\s*)?["']([^"'\n]+)["']/g;function Ue(e,t){let n=t.trim().split(/[?#]/)[0];if(!n||/^[a-z][a-z0-9+.-]*:/i.test(n)||n.startsWith("//"))return null;let s=n.startsWith("/")?[]:e.split("/").slice(0,-1);for(let a of n.split("/"))if(!(a===""||a==="."))if(a===".."){if(!s.length)return null;s.pop()}else s.push(a);return s.join("/")||null}function Ce(e){if(!e.has(x))return[];let t=new Set([x]),n=[x],s=a=>new TextDecoder().decode(e.get(a));for(;n.length;){let a=n.shift(),o=S(a)?.kind,u=[],d=(r,p)=>{r.lastIndex=0;for(let i=r.exec(p);i;i=r.exec(p))u.push(i[1]??i[2])};if(o==="html"){let r=s(a);d(ze,r),d(te,r)}else if(o==="style")d(te,s(a));else if(o==="script")d(Re,s(a));else continue;for(let r of u){let p=Ue(a,r);p&&e.has(p)&&!t.has(p)&&(t.add(p),n.push(p))}}return[...t].sort()}var _=e=>`${(e/(1024*1024)).toFixed(1)} MB`,P=e=>e<1024*1024?`${Math.round(e/1024)} KB`:_(e);function se(e){let t=e instanceof Map?e:new Map([...e].map(d=>[d.path,d.bytes])),n=be,s={images:[],sounds:[],decodedBytes:0,start:{files:[],bytes:0},issues:[],advice:[]},a=(d,r,p)=>s.issues.push({path:d,code:r,message:p}),o=(d,r,p)=>s.advice.push({path:d,code:r,message:p});for(let[d,r]of[...t].sort(([p],[i])=>p<i?-1:1)){let p=S(d),i=v(d);if(p?.kind==="image"&&i!=="svg"){let c=ne(d,r);if(!c||!c.width||!c.height){i==="avif"?o(d,"image_size_unknown","Seek could not read the size of this AVIF image, so it is not counted in the memory budget. WebP or PNG is safer."):a(d,"image_unreadable","Seek cannot read the size of this image: the file looks broken. Export it again.");continue}let l=c.width*c.height*4;s.images.push({path:d,width:c.width,height:c.height,bytes:r.byteLength,decoded:l,...c.alpha!==void 0?{alpha:c.alpha}:{}}),s.decodedBytes+=l;let f=Math.max(c.width,c.height);f>n.imageMaxSide?a(d,"image_too_large",`${c.width} x ${c.height} pixels: an image is at most ${n.imageMaxSide} pixels on its longest side. Phones cannot keep larger ones in memory. seek optimize scales it down.`):f>n.imageAdviseSide&&o(d,"image_large",`${c.width} x ${c.height} pixels takes ${_(l)} of memory once loaded. ${n.imageAdviseSide} pixels on the longest side is plenty for a phone; seek optimize scales it down.`),i==="png"&&c.alpha===!1&&r.byteLength>n.opaquePngAdviseBytes&&o(d,"opaque_png",`A ${P(r.byteLength)} PNG without transparency is probably a photo. As WebP or JPEG it is often 5 to 10 times smaller: seek optimize --webp.`),i==="gif"&&r.byteLength>n.gifAdviseBytes&&o(d,"heavy_gif",`A ${P(r.byteLength)} GIF. Animated GIFs are heavy to download and to play; an animated WebP or a sprite sheet is far lighter.`)}else if(p?.kind==="audio"){let c=Be(d,r);s.sounds.push({path:d,seconds:c,bytes:r.byteLength}),c!==null&&c>n.audioMaxSeconds&&a(d,"audio_too_long",`This sound lasts ${Math.round(c)} seconds; one sound is at most ${n.audioMaxSeconds/60} minutes.`),i==="wav"&&(r.byteLength>n.wavMaxBytes?a(d,"wav_too_big",`A WAV of ${P(r.byteLength)} is uncompressed sound: at most ${P(n.wavMaxBytes)}. As M4A it is about a tenth of the size: seek optimize --audio.`):r.byteLength>n.wavAdviseBytes&&o(d,"wav_audio",`A WAV is uncompressed sound. As M4A this ${P(r.byteLength)} is about a tenth: seek optimize --audio.`))}else p?.kind==="font"&&(i==="ttf"||i==="otf")&&o(d,"font_format",`A .${i} font: as WOFF2 it is usually 30 to 50% smaller and loads the same everywhere.`)}s.decodedBytes>n.decodedMaxBytes?a("","memory_too_big",`All images together take ${_(s.decodedBytes)} of memory once loaded; a game may use at most ${_(n.decodedMaxBytes)}. Scale the largest ones down (seek optimize) or load them only when needed.`):s.decodedBytes>n.decodedAdviseBytes&&o("","memory_heavy",`All images together take ${_(s.decodedBytes)} of memory once loaded. Older phones start to struggle above ${_(n.decodedAdviseBytes)}.`);let u=Ce(t);return s.start={files:u,bytes:u.reduce((d,r)=>d+(t.get(r)?.byteLength??0),0)},s.start.bytes>n.startMaxBytes?a(x,"start_too_big",`index.html loads ${_(s.start.bytes)} before the game can start; at most ${_(n.startMaxBytes)}. On a phone outside that takes far too long. Load the rest after ready(), with import() or fetch().`):s.start.bytes>n.startAdviseBytes&&o(x,"start_heavy",`index.html loads ${_(s.start.bytes)} before the game can start. Outside, on 4G, every MB is about a second of waiting; keep it under ${_(n.startAdviseBytes)} and load the rest after ready().`),s}function $t(e){let t=ke;if(e.byteLength>t.maxBytes)return{code:"too_big",message:"A cover is at most 1 MB."};let n=e,s=n[0]===137&&n[1]===80?"png":n[0]===255&&n[1]===216?"jpg":k(n,0,4)==="RIFF"&&k(n,8,4)==="WEBP"?"webp":null;if(!s)return{code:"bad_cover",message:"A cover is a PNG, JPEG or WebP image."};let a=ne(`cover.${s}`,e);if(!a)return{code:"bad_cover",message:"Seek cannot read the size of this image: export it again."};let{width:o,height:u}=a;return o<t.minWidth||u<t.minHeight?{code:"cover_too_small",message:`A cover is at least ${t.minWidth} x ${t.minHeight} pixels; this one is ${o} x ${u}.`}:o>t.maxWidth||u>t.maxHeight?{code:"cover_too_large",message:`A cover is at most ${t.maxWidth} x ${t.maxHeight} pixels; this one is ${o} x ${u}.`}:Math.abs(o/u/t.ratio-1)>t.ratioTolerance?{code:"cover_ratio",message:`A cover is 16:9, like 1920 x 1080; this one is ${o} x ${u}.`}:null}var ae=1,It=/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]-[0-9a-f]{16}$/,Ge="play.seekprotocol.ai",ie="https://api.play.seekprotocol.ai",A={files:200,fileBytes:5*1024*1024,totalBytes:15*1024*1024,pathLength:120,depth:5,segment:/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/,storageBytes:64*1024},Le={html:{type:"text/html; charset=utf-8",kind:"html",text:!0},js:{type:"text/javascript; charset=utf-8",kind:"script",text:!0},mjs:{type:"text/javascript; charset=utf-8",kind:"script",text:!0},css:{type:"text/css; charset=utf-8",kind:"style",text:!0},json:{type:"application/json; charset=utf-8",kind:"data",text:!0},svg:{type:"image/svg+xml",kind:"image",text:!0},png:{type:"image/png",kind:"image",text:!1},jpg:{type:"image/jpeg",kind:"image",text:!1},jpeg:{type:"image/jpeg",kind:"image",text:!1},gif:{type:"image/gif",kind:"image",text:!1},webp:{type:"image/webp",kind:"image",text:!1},avif:{type:"image/avif",kind:"image",text:!1},mp3:{type:"audio/mpeg",kind:"audio",text:!1},ogg:{type:"audio/ogg",kind:"audio",text:!1},wav:{type:"audio/wav",kind:"audio",text:!1},m4a:{type:"audio/mp4",kind:"audio",text:!1},woff:{type:"font/woff",kind:"font",text:!1},woff2:{type:"font/woff2",kind:"font",text:!1},ttf:{type:"font/ttf",kind:"font",text:!1},otf:{type:"font/otf",kind:"font",text:!1}},x="index.html",Oe=/^[0-9a-f]{64}$/,je=e=>[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join(""),R=async e=>je(await crypto.subtle.digest("SHA-256",typeof e=="string"?new TextEncoder().encode(e):e)),v=e=>/\.([a-z0-9]+)$/i.exec(e)?.[1]?.toLowerCase()??"",S=e=>Le[v(e)];function N(e){let t=(s,a)=>({path:e,code:s,message:a});if(typeof e!="string"||!e)return t("bad_path","A file needs a path.");if(e.length>A.pathLength)return t("path_too_long",`A path is at most ${A.pathLength} characters.`);let n=e.split("/");return n.length>A.depth?t("path_too_deep",`Folders go at most ${A.depth-1} deep.`):n.every(s=>A.segment.test(s))?S(e)?null:t("file_type",`".${v(e)||"(none)"}" files are not allowed. A game is html, js, css, json, images, audio and fonts.`):t("bad_path",'Use letters, digits, dots, hyphens and underscores; no hidden files, spaces or "..".')}var y=(e,t,n=0)=>t.every((s,a)=>e[n+a]===s),Ne=[["a Windows executable",[77,90]],["an ELF executable",[127,69,76,70]],["a Mach-O executable",[207,250,237,254]],["a Mach-O executable",[206,250,237,254]],["a Mach-O executable",[254,237,250,207]],["a Mach-O or Java binary",[202,254,186,190]],["a script with an interpreter line",[35,33]],["a zip, jar or apk archive",[80,75,3,4]],["WebAssembly",[0,97,115,109]],["a gzip archive",[31,139]],["a PDF",[37,80,68,70]]],De={png:e=>y(e,[137,80,78,71,13,10,26,10]),jpg:e=>y(e,[255,216,255]),jpeg:e=>y(e,[255,216,255]),gif:e=>y(e,[71,73,70,56]),webp:e=>y(e,[82,73,70,70])&&y(e,[87,69,66,80],8),wav:e=>y(e,[82,73,70,70])&&y(e,[87,65,86,69],8),ogg:e=>y(e,[79,103,103,83]),woff:e=>y(e,[119,79,70,70]),woff2:e=>y(e,[119,79,70,50])};function re(e,t){let n=S(e);if(!n)return N(e);let s=v(e);for(let[o,u]of Ne)if((!n.text||u[0]===35)&&y(t,u))return{path:e,code:"executable",message:`This file is ${o}, which a game cannot carry.`};if(n.text){if(t.includes(0))return{path:e,code:"not_text",message:"A text file must not contain NUL bytes."};try{new TextDecoder("utf-8",{fatal:!0}).decode(t)}catch{return{path:e,code:"not_text",message:"A text file must be UTF-8."}}return null}let a=De[s];return a&&!a(t)?{path:e,code:"type_mismatch",message:`This file is not really a .${s} file.`}:null}function Fe(e){let t=[],n=(d,r,p)=>t.push({path:d,code:r,message:p}),s=e;if(!s||typeof s!="object"||Array.isArray(s))return[{path:"",code:"bad_game",message:"Not a game manifest."}];let a=new Set(["format","id","entry","files","size"]);for(let d of Object.keys(s))a.has(d)||n(d,"bad_game",`Unknown field "${d}".`);if(s.format!==ae&&n("format","bad_format",`Unknown game format ${String(s.format)}.`),(typeof s.id!="string"||!$.test(s.id))&&n("id","bad_game_id","A game id is 3 to 30 lowercase letters, digits and hyphens."),s.entry!==x&&n("entry","bad_entry",`A game starts at ${x}.`),!Array.isArray(s.files))return[...t,{path:"files",code:"bad_game",message:"A game lists its files."}];s.files.length>A.files&&n("files","too_many_files",`A game has at most ${A.files} files; this one has ${s.files.length}.`);let o=0,u="";for(let[d,r]of s.files.entries()){let p=`files[${d}]`;if(!r||typeof r!="object"||Object.keys(r).sort().join()!=="path,sha256,size,type"){n(p,"bad_game","A file is { path, size, sha256, type }.");continue}let i=N(r.path);i?n(r.path||p,i.code,i.message):r.type!==S(r.path).type&&n(r.path,"bad_type",`A .${v(r.path)} file is served as ${S(r.path).type}.`),typeof r.path=="string"&&r.path<=u&&n(r.path,"not_sorted","Files are sorted by path, each once."),typeof r.path=="string"&&(u=r.path),!Number.isSafeInteger(r.size)||r.size<0?n(r.path||p,"bad_size","A size is a whole number of bytes."):r.size>A.fileBytes?n(r.path,"file_too_big",`A file is at most ${A.fileBytes} bytes; this one is ${r.size}.`):o+=r.size,(typeof r.sha256!="string"||!Oe.test(r.sha256))&&n(r.path||p,"bad_sha256","sha256 is 64 lowercase hex characters.")}return o>A.totalBytes&&n("files","game_too_big",`A game is at most ${A.totalBytes} bytes; this one is ${o}.`),s.size!==o&&n("size","bad_size","size is the sum of the file sizes."),s.files.some(d=>d?.path===x)||n(x,"entry_missing",`A game needs ${x} at the top of its folder.`),t}var We=e=>R(M(e));async function Bt(e,t){return t.byteLength!==e.size?{path:e.path,code:"size_mismatch",message:`The manifest says ${e.size} bytes; the file has ${t.byteLength}.`}:await R(t)!==e.sha256?{path:e.path,code:"sha256_mismatch",message:"The file does not match its sha256."}:re(e.path,t)}async function zt(e,t){let n=[],s=[...t].sort((r,p)=>r.path<p.path?-1:r.path>p.path?1:0),a=[];for(let r of s){let p=N(r.path)??re(r.path,r.bytes);p&&n.push(p),a.push({path:r.path,size:r.bytes.byteLength,sha256:await R(r.bytes),type:S(r.path)?.type??"application/octet-stream"})}let o={format:ae,id:e,entry:x,files:a,size:a.reduce((r,p)=>r+p.size,0)},u=new Set(n.map(r=>`${r.path}:${r.code}`));for(let r of Fe(o))!u.has(`${r.path}:${r.code}`)&&r.code!=="bad_type"&&n.push(r);let d=se(s.filter(r=>!n.some(p=>p.path===r.path)));return n.push(...d.issues),{manifest:o,hash:await We(o),issues:n,assets:d}}async function Rt(e,t){return`${t}-${(await R(e.toLowerCase())).slice(0,16)}`}var Ut=e=>`https://${e}.${Ge}`,Qe=/\b(?:https?|wss?):\/\/[^\s"'`<>()\\]+/gi,Ke=/^(?:https?:\/\/www\.w3\.org\/|https:\/\/storeconnect\.seekprotocol\.ai(?:$|\/)|http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?(?:$|\/))/i,Je=/\s(src|href|action|formaction|poster|data|srcset|background|xlink:href|manifest)\s*=\s*["']?\s*((?:(?:https?|wss?):)?\/\/[^"'\s>]+)/gi,He=/(?:url\(\s*["']?|@import\s+["'])\s*((?:(?:https?):)?\/\/[^"')\s]+)/gi,Ve=[["eval",/\beval\s*\(/g,"eval() runs text as code."],["new_function",/\bnew\s+Function\s*\(/g,"new Function() runs text as code."],["string_timer",/\bset(?:Timeout|Interval)\s*\(\s*["'`]/g,"A timer with a string runs text as code."],["document_write",/\bdocument\.write(?:ln)?\s*\(/g,"document.write() writes markup at run time."],["window_open",/\bwindow\.open\s*\(/g,"Opening windows is not allowed."],["navigation",/\b(?:(?:top|parent|window|document)\.location|location\.href)\s*=(?!=)/g,"A game does not navigate away."],["javascript_url",/javascript\s*:/gi,"javascript: URLs run code."]],Xe=/<[a-z][^>]*?\son[a-z]+\s*=/gi,Ye=/<script\b(?![^>]*\bsrc\s*=)[^>]*>\s*[^<\s]/gi,Ze=/<meta[^>]+http-equiv\s*=\s*["']?refresh/gi,qe=/<base\b/gi,j=e=>e===ie||e.startsWith(`${ie}/`);function Ct(e,t,n=20){let s=S(e);if(!s?.text||s.kind==="data")return[];let a=[],o=[];for(let i=t.indexOf(`
|
|
4
|
+
`);i!==-1;i=t.indexOf(`
|
|
5
|
+
`,i+1))o.push(i);let u=i=>{let c=0,l=o.length;for(;c<l;){let f=c+l>>1;o[f]<i?c=f+1:l=f}return c+1},d=(i,c,l,f,h)=>{a.length<n&&!a.some(g=>g.code===c&&g.line===u(i))&&a.push({path:e,line:u(i),code:c,severity:l,message:f,match:h.slice(0,120)})},r=(i,c)=>{i.lastIndex=0;for(let l=i.exec(t);l;l=i.exec(t))c(l)},p=new Set;if((s.kind==="html"||s.kind==="image")&&(r(Je,i=>{j(i[2])||(p.add(u(i.index)),d(i.index,"remote_url","fail",`"${i[1]}" points outside the game. Everything a game loads ships inside it.`,i[2]))}),r(Xe,i=>d(i.index,"inline_handler","flag","Inline event handlers do not run under the CSP; use addEventListener in a .js file.",i[0])),r(Ye,i=>d(i.index,"inline_script","flag","Inline scripts do not run under the CSP; put the code in a .js file.",i[0])),r(Ze,i=>d(i.index,"meta_refresh","fail","A game does not redirect.",i[0])),r(qe,i=>d(i.index,"base_tag","flag","A <base> tag changes where relative URLs point.",i[0]))),(s.kind==="style"||s.kind==="html")&&r(He,i=>{j(i[1])||(p.add(u(i.index)),d(i.index,"remote_url","fail","CSS that loads from outside the game.",i[1]))}),s.kind==="script"||s.kind==="html")for(let[i,c,l]of Ve)r(c,f=>d(f.index,i,"flag",l,f[0]));return r(Qe,i=>{j(i[0])||Ke.test(i[0])||p.has(u(i.index))||d(i.index,"remote_url_text","flag","A URL outside the game. The CSP blocks it; a reviewer checks why it is there.",i[0])}),a}export{be as ASSET_LIMITS,F as BUNDLE_FORMAT,Q as CAPABILITIES,ke as COVER_SPEC,U as DECLARATION_VALUES,It as GAME_APP_ID_PATTERN,x as GAME_ENTRY,Le as GAME_FILE_TYPES,ae as GAME_FORMAT,Ge as GAME_HOST_SUFFIX,$ as GAME_ID_PATTERN,A as GAME_LIMITS,ie as GAME_PROXY_ORIGIN,m as LIMITS,he as LOG_GENESIS,we as PACKAGE_EXTENSION,L as PACKAGE_LIMITS,w as PackageError,T as QuestInvalidError,W as SDK_RUNTIME,le as VERSION_PATTERN,se as analyzeGameAssets,Be as audioSeconds,G as bundleHash,M as canonicalJson,J as compile,$t as coverProblem,ye as decompile,ce as distanceM,v as extensionOf,Rt as gameAppId,S as gameFileType,We as gameHash,Ut as gameOrigin,N as gamePathProblem,pt as generateSigningKey,ne as imageSize,me as keyIdOf,Ct as lintGameText,xe as logEntryHash,kt as packageFileName,zt as packageGame,nt as quest,_t as readPackage,at as reward,ft as signHash,H as signedMessage,re as sniffGameFile,Ce as startFiles,it as step,z as successors,tt as t,de as usedCapabilities,K as validate,Fe as validateGameManifest,X as verifyBundle,Bt as verifyGameFile,ge as verifyHash,ht as verifyLogChain,mt as verifySeekSignature,At as writePackage,st as zone};
|
package/log.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transparency log: every signature Seek ever made, in order.
|
|
3
|
+
*
|
|
4
|
+
* Each entry's hash covers the entry before it, so the newest hash commits to
|
|
5
|
+
* the whole history: change or drop one entry and every hash after it differs.
|
|
6
|
+
* Supabase writes the log (`platform__log_append`, which must build the same
|
|
7
|
+
* string as `logEntryHash`); anyone holding the entries can check it here, and
|
|
8
|
+
* compare the newest hash with one that was published earlier.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LOG_GENESIS: string;
|
|
11
|
+
export interface LogEntry {
|
|
12
|
+
seq: number;
|
|
13
|
+
/** `signed` on approval, `resigned` when a key rotation replaced the signature. */
|
|
14
|
+
event: 'signed' | 'resigned';
|
|
15
|
+
quest_id: string;
|
|
16
|
+
version_id: string;
|
|
17
|
+
hash: string;
|
|
18
|
+
seek_key_id: string;
|
|
19
|
+
seek_sig: string;
|
|
20
|
+
prev_entry: string;
|
|
21
|
+
entry_hash: string;
|
|
22
|
+
}
|
|
23
|
+
export type LogCheck = {
|
|
24
|
+
ok: true;
|
|
25
|
+
size: number;
|
|
26
|
+
head: string;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
seq: number;
|
|
30
|
+
reason: 'bad_sequence' | 'bad_link' | 'bad_hash';
|
|
31
|
+
};
|
|
32
|
+
export declare function logEntryHash(e: Omit<LogEntry, 'entry_hash'>): Promise<string>;
|
|
33
|
+
/** Walk entries from the start of the log, or on from `from` when continuing an earlier check. */
|
|
34
|
+
export declare function verifyLogChain(entries: LogEntry[], from?: {
|
|
35
|
+
size: number;
|
|
36
|
+
head: string;
|
|
37
|
+
}): Promise<LogCheck>;
|
package/package.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.seek` file: one quest build, what an `.ipa` is on iOS.
|
|
3
|
+
*
|
|
4
|
+
* `seek build` makes it and signs it with the maker's key, because that key
|
|
5
|
+
* never leaves the maker's machine. The maker uploads it with `seek push` or by
|
|
6
|
+
* dropping it on Store Connect. Either way it is unpacked on the maker's side
|
|
7
|
+
* and goes through the same API, which trusts none of it: Store Connect checks
|
|
8
|
+
* the bundle, the signature and every game file as it always does.
|
|
9
|
+
*
|
|
10
|
+
* It is gzipped JSON, so nothing is ever extracted to a path, and a file that
|
|
11
|
+
* inflates without end stops at `PACKAGE_LIMITS.unpackedBytes`.
|
|
12
|
+
*/
|
|
13
|
+
import type { GameManifest } from './games.js';
|
|
14
|
+
import type { Signature } from './sign.js';
|
|
15
|
+
import type { QuestBundle } from './types.js';
|
|
16
|
+
export declare const PACKAGE_EXTENSION = ".seek";
|
|
17
|
+
export declare const PACKAGE_LIMITS: {
|
|
18
|
+
readonly fileBytes: number;
|
|
19
|
+
readonly unpackedBytes: number;
|
|
20
|
+
};
|
|
21
|
+
/** One game as `POST /quests/:quest/games` takes it: `files` is path to base64. */
|
|
22
|
+
export interface GameUpload {
|
|
23
|
+
manifest: GameManifest;
|
|
24
|
+
hash: string;
|
|
25
|
+
files: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
export interface QuestPackage {
|
|
28
|
+
format: 1;
|
|
29
|
+
bundle: QuestBundle;
|
|
30
|
+
hash: string;
|
|
31
|
+
/** Null when it was built without a signing key: the Simulator takes it, Store Connect does not. */
|
|
32
|
+
signature: Signature | null;
|
|
33
|
+
/** By game id, every game `bundle.manifest.games` names. */
|
|
34
|
+
games: Record<string, GameUpload>;
|
|
35
|
+
}
|
|
36
|
+
export declare class PackageError extends Error {
|
|
37
|
+
}
|
|
38
|
+
export declare const packageFileName: (b: QuestBundle) => string;
|
|
39
|
+
export declare function writePackage(p: Omit<QuestPackage, 'format'>): Promise<Uint8Array>;
|
|
40
|
+
/** The contents of a `.seek` file, once it is whole and the bundle is the one its hash names. */
|
|
41
|
+
export declare function readPackage(bytes: Uint8Array): Promise<QuestPackage>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekprotocol/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build quests for Seek: the kits, the builders, the validator and the bundle compiler.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Seek Protocol",
|
|
7
|
+
"url": "https://www.seekprotocol.ai/en"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://www.seekprotocol.ai/en",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"seek",
|
|
12
|
+
"seekprotocol",
|
|
13
|
+
"quests",
|
|
14
|
+
"location-based",
|
|
15
|
+
"games"
|
|
16
|
+
],
|
|
17
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "index.js",
|
|
20
|
+
"types": "index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./index.d.ts",
|
|
24
|
+
"default": "./index.js"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/sign.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signatures on a bundle, the way code signing works on iOS.
|
|
3
|
+
*
|
|
4
|
+
* The CLI signs the bundle hash with the maker's key on `seek submit`, so
|
|
5
|
+
* Store Connect knows the upload came from that maker. After approval Seek
|
|
6
|
+
* signs the same hash with its own key, and the app only shows a bundle that
|
|
7
|
+
* carries Seek's signature. Ed25519 through WebCrypto, so the same code runs
|
|
8
|
+
* in Node, in a Worker and in a Deno edge function.
|
|
9
|
+
*/
|
|
10
|
+
export interface Signature {
|
|
11
|
+
/** Who signed: the maker on submit, Seek on approval. */
|
|
12
|
+
by: 'maker' | 'seek';
|
|
13
|
+
/** Which key, so keys can be rotated. */
|
|
14
|
+
keyId: string;
|
|
15
|
+
/** Ed25519 over the UTF-8 bytes of `signedMessage(hash, by)`, base64url. */
|
|
16
|
+
sig: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What a signature covers: the purpose, the signer's role and the hash. A
|
|
20
|
+
* signature made here can never pass for one on anything else, and a maker's
|
|
21
|
+
* can never pass for Seek's.
|
|
22
|
+
*/
|
|
23
|
+
export declare const signedMessage: (hash: string, by: Signature["by"]) => string;
|
|
24
|
+
/** The public keys Seek's signatures are checked against, by `keyId`. A rotated key stays until nothing needs it. */
|
|
25
|
+
export type Keyring = Record<string, JsonWebKey>;
|
|
26
|
+
export interface KeyPairJwk {
|
|
27
|
+
keyId: string;
|
|
28
|
+
publicJwk: JsonWebKey;
|
|
29
|
+
privateJwk: JsonWebKey;
|
|
30
|
+
}
|
|
31
|
+
/** A new signing key. `keyId` is the first 16 hex characters of the public key's SHA-256. */
|
|
32
|
+
export declare function generateSigningKey(): Promise<KeyPairJwk>;
|
|
33
|
+
/** The `keyId` of a key, from its public half: first 16 hex characters of the SHA-256. */
|
|
34
|
+
export declare function keyIdOf(jwk: JsonWebKey): Promise<string>;
|
|
35
|
+
export declare function signHash(hash: string, by: Signature['by'], keyId: string, privateJwk: JsonWebKey): Promise<Signature>;
|
|
36
|
+
export declare function verifyHash(hash: string, signature: Signature, publicJwk: JsonWebKey): Promise<boolean>;
|
|
37
|
+
/** True when a key of the ring, named by the signature itself, made this Seek signature. */
|
|
38
|
+
export declare function verifySeekSignature(hash: string, signature: Signature, ring: Keyring): Promise<boolean>;
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a quest is, as a maker writes it and as Seek runs it.
|
|
3
|
+
*
|
|
4
|
+
* A maker describes a quest with the builders in `./builders`; `compile`
|
|
5
|
+
* turns that description into a `QuestBundle`, which is plain JSON. The
|
|
6
|
+
* bundle is the only thing that leaves the maker's machine, and Seek's
|
|
7
|
+
* server is the only thing that runs it: no maker code is executed on a
|
|
8
|
+
* phone or on Seek's side. What a step can ask for is exactly what this file
|
|
9
|
+
* lists, and every one of those things is a fact the server can check
|
|
10
|
+
* itself (an attested position, a catch, a verified step count, an arena win).
|
|
11
|
+
*/
|
|
12
|
+
/** The bundle format. Bumped only for a change an older Seek cannot read. */
|
|
13
|
+
export declare const BUNDLE_FORMAT: 1;
|
|
14
|
+
/**
|
|
15
|
+
* The quest runtime this SDK targets.
|
|
16
|
+
*
|
|
17
|
+
* The app reports which runtime it carries; a bundle that needs a newer one is
|
|
18
|
+
* only offered to players whose app has it, the way an iOS app names its
|
|
19
|
+
* minimum iOS version.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SDK_RUNTIME: 1;
|
|
22
|
+
/** A minigame's id inside its quest: its folder `games/<id>/` and `step.play({ game })`. 3 to 30 characters. */
|
|
23
|
+
export declare const GAME_ID_PATTERN: RegExp;
|
|
24
|
+
/** A piece of player-facing text, written in English; Seek translates it. */
|
|
25
|
+
export interface Text {
|
|
26
|
+
en: string;
|
|
27
|
+
}
|
|
28
|
+
export interface Point {
|
|
29
|
+
lat: number;
|
|
30
|
+
lng: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where the quest is played.
|
|
34
|
+
*
|
|
35
|
+
* Seek decides where each marker actually stands inside it (on a street a
|
|
36
|
+
* player can walk to, never in a building or a no-go zone), so an area is a
|
|
37
|
+
* playing field and not an address.
|
|
38
|
+
*/
|
|
39
|
+
export type Area = {
|
|
40
|
+
kind: 'anywhere';
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'circle';
|
|
43
|
+
name: string;
|
|
44
|
+
center: Point;
|
|
45
|
+
radiusM: number;
|
|
46
|
+
};
|
|
47
|
+
/** A place a step points at. Seek moves the marker to the nearest safe, walkable spot. */
|
|
48
|
+
export interface Place {
|
|
49
|
+
name: string;
|
|
50
|
+
at: Point;
|
|
51
|
+
}
|
|
52
|
+
/** Everything a bundle may be granted. Each is approved by Seek on its own. */
|
|
53
|
+
export declare const CAPABILITIES: readonly ["location.markers", "spawns.place", "catch", "steps", "arena", "rewards.cards", "rewards.cosmetics", "rewards.boosts", "rewards.crypto", "gamekit"];
|
|
54
|
+
export type Capability = (typeof CAPABILITIES)[number];
|
|
55
|
+
interface StepBase {
|
|
56
|
+
/** Stable across versions: a player's progress names it. */
|
|
57
|
+
id: string;
|
|
58
|
+
/** One line in the tracker: what to do now. */
|
|
59
|
+
title: Text;
|
|
60
|
+
/** The longer telling, shown when the step opens. */
|
|
61
|
+
story?: Text;
|
|
62
|
+
/** The step that follows. Defaults to the next one in the list; `null` ends the quest. */
|
|
63
|
+
next?: string | null;
|
|
64
|
+
}
|
|
65
|
+
/** Reach the quest-giver and hear them out. */
|
|
66
|
+
export interface TalkStep extends StepBase {
|
|
67
|
+
kind: 'talk';
|
|
68
|
+
/** A character from Seek's catalogue, or one the maker had approved. */
|
|
69
|
+
giver: string;
|
|
70
|
+
/** Where the giver stands. Defaults to the centre of the area. */
|
|
71
|
+
at?: Place;
|
|
72
|
+
}
|
|
73
|
+
/** Reach a place. */
|
|
74
|
+
export interface ArriveStep extends StepBase {
|
|
75
|
+
kind: 'arrive';
|
|
76
|
+
at: Place;
|
|
77
|
+
/** How close counts as there, in metres. */
|
|
78
|
+
radiusM: number;
|
|
79
|
+
}
|
|
80
|
+
/** Catch coins. */
|
|
81
|
+
export interface CatchStep extends StepBase {
|
|
82
|
+
kind: 'catch';
|
|
83
|
+
/** `memecoins.key`, or `any` for any coin. */
|
|
84
|
+
coin: string;
|
|
85
|
+
count: number;
|
|
86
|
+
/** Only catches near this place count, and Seek may put coins there. */
|
|
87
|
+
near?: Place;
|
|
88
|
+
withinM?: number;
|
|
89
|
+
}
|
|
90
|
+
/** Walk. */
|
|
91
|
+
export interface WalkStep extends StepBase {
|
|
92
|
+
kind: 'walk';
|
|
93
|
+
steps: number;
|
|
94
|
+
/** Only steps Seek verified (walked while hunting) count. */
|
|
95
|
+
verified: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** Win arena matches. */
|
|
98
|
+
export interface ArenaStep extends StepBase {
|
|
99
|
+
kind: 'arena';
|
|
100
|
+
wins: number;
|
|
101
|
+
}
|
|
102
|
+
/** The player chooses; each option leads to its own step. */
|
|
103
|
+
export interface ChoiceStep extends StepBase {
|
|
104
|
+
kind: 'choice';
|
|
105
|
+
options: {
|
|
106
|
+
id: string;
|
|
107
|
+
label: Text;
|
|
108
|
+
next: string | null;
|
|
109
|
+
}[];
|
|
110
|
+
}
|
|
111
|
+
/** Play an HTML5 minigame. The score is advisory; Seek checks it against the reviewed envelope. */
|
|
112
|
+
export interface PlayStep extends StepBase {
|
|
113
|
+
kind: 'play';
|
|
114
|
+
/** The minigame's folder in the quest project, `games/<game>/`. */
|
|
115
|
+
game: string;
|
|
116
|
+
/** The score that completes the step. */
|
|
117
|
+
minScore: number;
|
|
118
|
+
/** The highest score a real run can reach, fixed at review. */
|
|
119
|
+
maxScore: number;
|
|
120
|
+
}
|
|
121
|
+
export type Step = TalkStep | ArriveStep | CatchStep | WalkStep | ArenaStep | ChoiceStep | PlayStep;
|
|
122
|
+
export type StepKind = Step['kind'];
|
|
123
|
+
/**
|
|
124
|
+
* What finishing the quest is worth.
|
|
125
|
+
*
|
|
126
|
+
* The maker picks from Seek's catalogue; Seek funds it, caps it and pays it.
|
|
127
|
+
* The numbers here are what the bundle asks for, and the server decides what
|
|
128
|
+
* is actually granted.
|
|
129
|
+
*/
|
|
130
|
+
export type Reward = {
|
|
131
|
+
kind: 'xp';
|
|
132
|
+
amount: number;
|
|
133
|
+
} | {
|
|
134
|
+
kind: 'card';
|
|
135
|
+
card: string;
|
|
136
|
+
} | {
|
|
137
|
+
kind: 'cosmetic';
|
|
138
|
+
item: string;
|
|
139
|
+
} | {
|
|
140
|
+
kind: 'boost';
|
|
141
|
+
boost: string;
|
|
142
|
+
count: number;
|
|
143
|
+
} | {
|
|
144
|
+
kind: 'pool';
|
|
145
|
+
coin: string;
|
|
146
|
+
perPlayer: number;
|
|
147
|
+
maxPlayers: number;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* What the maker declares about the quest, as stores ask for a content rating
|
|
151
|
+
* and a privacy label. Seek shows it to players and reviewers, checks what it
|
|
152
|
+
* can against the steps, and a wrong declaration is a reason to reject.
|
|
153
|
+
*/
|
|
154
|
+
export interface Declarations {
|
|
155
|
+
/** Where players are sent: public places only, or also private ground the maker has permission for. */
|
|
156
|
+
places: 'public' | 'private_with_permission';
|
|
157
|
+
/** How hard it is on the body, all steps together. */
|
|
158
|
+
effort: 'light' | 'moderate' | 'hard';
|
|
159
|
+
/** Whether a step takes players near traffic, water, heights or other dangers they must watch for. */
|
|
160
|
+
hazards: boolean;
|
|
161
|
+
/** Whether AI wrote texts or made art or code in the quest. */
|
|
162
|
+
ai: 'none' | 'assisted' | 'generated';
|
|
163
|
+
/** Whether a brand paid for the quest or appears in it. Players are told. */
|
|
164
|
+
sponsored: boolean;
|
|
165
|
+
/** Whether the quest's minigames record gameplay events (GameKit `track`). */
|
|
166
|
+
gameEvents: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Required, and always true: every text, image, sound, font, character,
|
|
169
|
+
* logo and line of code in the quest and its games is yours, or you have a
|
|
170
|
+
* licence for this use. A build that says otherwise is not accepted.
|
|
171
|
+
*/
|
|
172
|
+
rights: true;
|
|
173
|
+
}
|
|
174
|
+
export declare const DECLARATION_VALUES: {
|
|
175
|
+
readonly places: readonly ["public", "private_with_permission"];
|
|
176
|
+
readonly effort: readonly ["light", "moderate", "hard"];
|
|
177
|
+
readonly ai: readonly ["none", "assisted", "generated"];
|
|
178
|
+
};
|
|
179
|
+
export interface QuestDefinition {
|
|
180
|
+
/** Unique per maker, lowercase: `[a-z0-9-]`, 3 to 40 long. */
|
|
181
|
+
id: string;
|
|
182
|
+
title: Text;
|
|
183
|
+
summary?: Text;
|
|
184
|
+
area: Area;
|
|
185
|
+
/** When the quest can be started and finished, ISO 8601. */
|
|
186
|
+
window?: {
|
|
187
|
+
from: string;
|
|
188
|
+
to: string;
|
|
189
|
+
};
|
|
190
|
+
/** Whether a finished quest can be played again. */
|
|
191
|
+
repeat?: 'never' | 'daily' | 'weekly';
|
|
192
|
+
capabilities: Capability[];
|
|
193
|
+
steps: Step[];
|
|
194
|
+
rewards: Reward[];
|
|
195
|
+
/** The youngest player who may see it. Anything that pays is 18. */
|
|
196
|
+
minAge?: 13 | 16 | 18;
|
|
197
|
+
/** Required: what the quest asks of players and what it contains. */
|
|
198
|
+
declarations: Declarations;
|
|
199
|
+
}
|
|
200
|
+
/** The text table of a bundle: key to English. Seek fills in the other 30 languages. */
|
|
201
|
+
export type TextTable = Record<string, string>;
|
|
202
|
+
/** A step as it is stored in a bundle: texts replaced by keys, `next` resolved. */
|
|
203
|
+
type CompileOne<S extends Step> = Omit<S, 'title' | 'story' | 'next' | 'options'> & {
|
|
204
|
+
title: string;
|
|
205
|
+
story?: string;
|
|
206
|
+
next: string | null;
|
|
207
|
+
} & (S extends ChoiceStep ? {
|
|
208
|
+
options: {
|
|
209
|
+
id: string;
|
|
210
|
+
label: string;
|
|
211
|
+
next: string | null;
|
|
212
|
+
}[];
|
|
213
|
+
} : unknown);
|
|
214
|
+
export type CompiledStep = {
|
|
215
|
+
[K in StepKind]: CompileOne<Extract<Step, {
|
|
216
|
+
kind: K;
|
|
217
|
+
}>>;
|
|
218
|
+
}[StepKind];
|
|
219
|
+
export interface QuestManifest {
|
|
220
|
+
format: typeof BUNDLE_FORMAT;
|
|
221
|
+
/** The SDK runtime the app must carry to show this bundle. */
|
|
222
|
+
runtime: number;
|
|
223
|
+
id: string;
|
|
224
|
+
/** Semantic version, raised by the maker for every submission. */
|
|
225
|
+
version: string;
|
|
226
|
+
/** Strictly increasing per quest, like an iOS build number. */
|
|
227
|
+
build: number;
|
|
228
|
+
title: string;
|
|
229
|
+
summary?: string;
|
|
230
|
+
area: Area;
|
|
231
|
+
window?: {
|
|
232
|
+
from: string;
|
|
233
|
+
to: string;
|
|
234
|
+
};
|
|
235
|
+
repeat: 'never' | 'daily' | 'weekly';
|
|
236
|
+
minAge: 13 | 16 | 18;
|
|
237
|
+
declarations: Declarations;
|
|
238
|
+
capabilities: Capability[];
|
|
239
|
+
rewards: Reward[];
|
|
240
|
+
/** The step a new player starts on. */
|
|
241
|
+
start: string;
|
|
242
|
+
/** GameKit: each `play` step's game, by id, to its game hash. Absent when the quest has no games. */
|
|
243
|
+
games?: Record<string, string>;
|
|
244
|
+
}
|
|
245
|
+
/** What `seek build` writes and `seek submit` uploads. Plain JSON. */
|
|
246
|
+
export interface QuestBundle {
|
|
247
|
+
manifest: QuestManifest;
|
|
248
|
+
steps: CompiledStep[];
|
|
249
|
+
texts: {
|
|
250
|
+
en: TextTable;
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export {};
|
package/validate.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a quest is one Seek can run.
|
|
3
|
+
*
|
|
4
|
+
* The same checks run in three places: `seek test` on the maker's machine,
|
|
5
|
+
* `seek submit` before the upload, and Store Connect's review pipeline on what
|
|
6
|
+
* arrived. A maker sees the answer the reviewer will see, before submitting.
|
|
7
|
+
*
|
|
8
|
+
* The limits are Seek's, not the maker's: they keep a quest walkable, finite
|
|
9
|
+
* and inside what the reward pipeline is built to pay.
|
|
10
|
+
*/
|
|
11
|
+
import { type Capability, type Point, type QuestDefinition, type Step } from './types.js';
|
|
12
|
+
export declare const LIMITS: {
|
|
13
|
+
readonly steps: {
|
|
14
|
+
readonly min: 1;
|
|
15
|
+
readonly max: 30;
|
|
16
|
+
};
|
|
17
|
+
readonly idPattern: RegExp;
|
|
18
|
+
readonly stepIdPattern: RegExp;
|
|
19
|
+
readonly title: 60;
|
|
20
|
+
readonly summary: 160;
|
|
21
|
+
readonly story: 600;
|
|
22
|
+
readonly areaRadiusM: {
|
|
23
|
+
readonly min: 100;
|
|
24
|
+
readonly max: 50000;
|
|
25
|
+
};
|
|
26
|
+
readonly arriveRadiusM: {
|
|
27
|
+
readonly min: 25;
|
|
28
|
+
readonly max: 500;
|
|
29
|
+
};
|
|
30
|
+
readonly catchCount: {
|
|
31
|
+
readonly min: 1;
|
|
32
|
+
readonly max: 50;
|
|
33
|
+
};
|
|
34
|
+
readonly catchWithinM: {
|
|
35
|
+
readonly min: 50;
|
|
36
|
+
readonly max: 2000;
|
|
37
|
+
};
|
|
38
|
+
readonly walkSteps: {
|
|
39
|
+
readonly min: 100;
|
|
40
|
+
readonly max: 50000;
|
|
41
|
+
};
|
|
42
|
+
readonly arenaWins: {
|
|
43
|
+
readonly min: 1;
|
|
44
|
+
readonly max: 20;
|
|
45
|
+
};
|
|
46
|
+
readonly choiceOptions: {
|
|
47
|
+
readonly min: 2;
|
|
48
|
+
readonly max: 4;
|
|
49
|
+
};
|
|
50
|
+
readonly xp: {
|
|
51
|
+
readonly max: 5000;
|
|
52
|
+
};
|
|
53
|
+
readonly boostCount: {
|
|
54
|
+
readonly max: 3;
|
|
55
|
+
};
|
|
56
|
+
readonly rewards: {
|
|
57
|
+
readonly max: 6;
|
|
58
|
+
};
|
|
59
|
+
readonly windowDays: {
|
|
60
|
+
readonly max: 92;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
export interface Issue {
|
|
64
|
+
/** Where, as a path into the definition: `steps[2].radiusM`. */
|
|
65
|
+
path: string;
|
|
66
|
+
code: string;
|
|
67
|
+
message: string;
|
|
68
|
+
}
|
|
69
|
+
/** Metres between two points, on a sphere. Good to well under a metre at these distances. */
|
|
70
|
+
export declare function distanceM(a: Point, b: Point): number;
|
|
71
|
+
/** The capabilities a definition actually uses, whether or not it declares them. */
|
|
72
|
+
export declare function usedCapabilities(q: QuestDefinition): Set<Capability>;
|
|
73
|
+
/** Where each step leads: its own `next`, its options, or the one after it. */
|
|
74
|
+
export declare function successors(steps: readonly Step[], index: number): (string | null)[];
|
|
75
|
+
export declare function validate(q: QuestDefinition): Issue[];
|
package/verify.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { QuestBundle, QuestDefinition } from './types.js';
|
|
2
|
+
import type { Issue } from './validate.js';
|
|
3
|
+
export declare function decompile(bundle: QuestBundle): QuestDefinition;
|
|
4
|
+
export type BundleCheck = {
|
|
5
|
+
ok: true;
|
|
6
|
+
hash: string;
|
|
7
|
+
definition: QuestDefinition;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
issues: Issue[];
|
|
11
|
+
};
|
|
12
|
+
export declare function verifyBundle(bundle: unknown, expectedHash?: string): Promise<BundleCheck>;
|