@origingame/engine 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/bin/og.mjs +25 -0
- package/dist/web/origin-engine.d.ts +1233 -0
- package/dist/web/origin-engine.js +8628 -0
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
- package/dist/web-publish/origin-engine-lanes.js +8509 -0
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +1176 -0
- package/dist/web-publish/origin-engine.js +8313 -0
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
- package/dist/web-publish-2d/origin-engine.js +8313 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/knowledge/README.md +31 -0
- package/knowledge/knowledge.json +13 -0
- package/knowledge/looks/catalogue.json +1871 -0
- package/knowledge/package.json +33 -0
- package/knowledge/rules/platform.json +329 -0
- package/knowledge/scaffold/README.md +27 -0
- package/knowledge/scaffold/distribution.json +48 -0
- package/knowledge/scaffold/origin-engine.json +19 -0
- package/knowledge/scaffold/template/.origin/setup +14 -0
- package/knowledge/scaffold/template/LICENSES.md +23 -0
- package/knowledge/scaffold/template/README.md +37 -0
- package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
- package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
- package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
- package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
- package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
- package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
- package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
- package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
- package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
- package/knowledge/scaffold/template/gitattributes +22 -0
- package/knowledge/scaffold/template/package.json +18 -0
- package/knowledge/scaffold/template/project.ogt +25 -0
- package/knowledge/scaffold/template/src/main.ts +246 -0
- package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
- package/knowledge/scaffold/template/tsconfig.json +17 -0
- package/knowledge/scripts/lib.ts +203 -0
- package/knowledge/scripts/mount.ts +210 -0
- package/knowledge/scripts/pack.ts +24 -0
- package/knowledge/scripts/scaffold.ts +72 -0
- package/knowledge/scripts/validate.ts +749 -0
- package/knowledge/skills/og-ai/SKILL.md +386 -0
- package/knowledge/skills/og-audio/SKILL.md +191 -0
- package/knowledge/skills/og-build/SKILL.md +330 -0
- package/knowledge/skills/og-core/SKILL.md +285 -0
- package/knowledge/skills/og-feel/SKILL.md +385 -0
- package/knowledge/skills/og-game/SKILL.md +1479 -0
- package/knowledge/skills/og-i18n/SKILL.md +220 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
- package/knowledge/skills/og-taste/SKILL.md +537 -0
- package/knowledge/skills/og-ui/SKILL.md +444 -0
- package/knowledge/wiki/ai.md +627 -0
- package/knowledge/wiki/anim.md +1187 -0
- package/knowledge/wiki/atmosphere.md +636 -0
- package/knowledge/wiki/audio.md +732 -0
- package/knowledge/wiki/camera.md +711 -0
- package/knowledge/wiki/commands.md +357 -0
- package/knowledge/wiki/game.md +220 -0
- package/knowledge/wiki/i18n.md +213 -0
- package/knowledge/wiki/index.md +1027 -0
- package/knowledge/wiki/input.md +1714 -0
- package/knowledge/wiki/load.md +1280 -0
- package/knowledge/wiki/math.md +166 -0
- package/knowledge/wiki/nav.md +1154 -0
- package/knowledge/wiki/physics.md +3142 -0
- package/knowledge/wiki/platform.md +857 -0
- package/knowledge/wiki/probe.md +1692 -0
- package/knowledge/wiki/reflect.md +285 -0
- package/knowledge/wiki/render.md +3511 -0
- package/knowledge/wiki/rng.md +393 -0
- package/knowledge/wiki/stream.md +376 -0
- package/knowledge/wiki/system.md +197 -0
- package/knowledge/wiki/systems.md +43 -0
- package/knowledge/wiki/terrain.md +727 -0
- package/knowledge/wiki/tilemap.md +422 -0
- package/knowledge/wiki/time.md +150 -0
- package/knowledge/wiki/transform.md +230 -0
- package/knowledge/wiki/tween.md +580 -0
- package/knowledge/wiki/ui.md +8000 -0
- package/knowledge/wiki/world.md +909 -0
- package/package.json +68 -4
- package/schema/api.schema.json +29675 -0
- package/shell/README.md +22 -0
- package/shell/brand/README.md +23 -0
- package/shell/brand/icon-192.png +0 -0
- package/shell/brand/icon-512.png +0 -0
- package/shell/brand/mark-on-paper.svg +6 -0
- package/shell/brand/origin-mark.svg +5 -0
- package/shell/brand/origin-motion.css +251 -0
- package/shell/brand/tokens.css +20 -0
- package/shell/brand.ts +132 -0
- package/shell/page.ts +715 -0
- package/shell/platform.ts +203 -0
- package/shell/player.ts +238 -0
- package/shell/shell.css +185 -0
- package/shell/sw.ts +177 -0
- package/shell/transport.ts +515 -0
- package/shell/wire.ts +369 -0
- package/src/e6.ts +69 -0
- package/src/env.d.ts +100 -0
- package/src/generated/abi.ts +980 -0
- package/src/index.ts +193 -0
- package/src/node/audio.ts +86 -0
- package/src/node/build.ts +109 -0
- package/src/node/device.ts +981 -0
- package/src/node/doctor.ts +60 -0
- package/src/node/index.ts +102 -0
- package/src/node/platform.ts +24 -0
- package/src/node/playtest-run.ts +249 -0
- package/src/node/png.ts +130 -0
- package/src/node/polish.ts +976 -0
- package/src/node/run.ts +373 -0
- package/src/node/web-build.ts +808 -0
- package/src/p2.ts +35 -0
- package/src/platform-local.ts +211 -0
- package/src/playtest.ts +131 -0
- package/src/runtime.ts +1509 -0
- package/src/systems/cameras.ts +247 -0
- package/src/ui-kit/bind.ts +141 -0
- package/src/ui-kit/huds.ts +303 -0
- package/src/ui-kit/index.ts +36 -0
- package/src/ui-kit/menus.ts +187 -0
- package/src/ui-kit/settings.ts +131 -0
- package/src/ui-kit/store.ts +222 -0
- package/src/ui-kit/story.ts +89 -0
- package/src/ui-kit/util.ts +106 -0
- package/src/web/a11y.ts +429 -0
- package/src/web/audio-worklet.ts +193 -0
- package/src/web/audio.ts +307 -0
- package/src/web/basis.ts +239 -0
- package/src/web/gamepad.ts +123 -0
- package/src/web/host.ts +166 -0
- package/src/web/input.ts +237 -0
- package/src/web/lanes.ts +21 -0
- package/src/web/play.ts +20 -0
- package/src/web/rpc-ring.ts +110 -0
- package/src/web/settle.ts +122 -0
- package/src/web/shell.ts +643 -0
- package/src/web/text-input.ts +248 -0
- package/src/web/worker.ts +1002 -0
- package/types/bindings.json +13654 -0
- package/types/index.d.ts +20090 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/NOTICE +15 -0
- package/vendor/basis/README.md +18 -0
- package/vendor/basis/basis_transcoder.js +19 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
- package/README.md +0 -3
package/shell/sw.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The exported game's service worker (sw.js, publish builds): every file of
|
|
2
|
+
// the build's first group is precached under a cache named by the build's
|
|
3
|
+
// content hash, so the game plays offline after its first load and a new
|
|
4
|
+
// build replaces the old one atomically. A streaming group's pack is not
|
|
5
|
+
// precached: it is cached in the same cache when the engine first fetches it,
|
|
6
|
+
// so a place the player has been plays offline too. `og build` prepends
|
|
7
|
+
// `const OG_SW = { version, files, lazy, single, lanes }` (the hash, the
|
|
8
|
+
// precached relative paths, the packs and long sounds' chunks, and the two
|
|
9
|
+
// engine variants' files)
|
|
10
|
+
// to this file's bundle.
|
|
11
|
+
//
|
|
12
|
+
// The engine comes in two variants (docs/web-export.md §Lanes) and a page
|
|
13
|
+
// boots one by the shell's rule (src/web/lanes.ts `laneChoice`: the build
|
|
14
|
+
// ships lanes, the page is cross-origin isolated, more than two cores). The
|
|
15
|
+
// worker precaches the variant the same rule picks in its own scope (it is
|
|
16
|
+
// isolated when sw.js got the page's COEP, which `_headers` sends to every
|
|
17
|
+
// file; a worker sees the page's core count) and caches the other on first
|
|
18
|
+
// use, like a pack; a wrong guess costs one download, never a failed boot
|
|
19
|
+
// (the engine Worker falls back to the single-threaded variant). Once the
|
|
20
|
+
// engine is ready, the page names the variant it booted (`og-cache`), and
|
|
21
|
+
// the worker caches whichever of that variant's files it lacks, so the
|
|
22
|
+
// variant the page runs plays offline even when the guess was wrong or the
|
|
23
|
+
// lanes pool failed and the page fell back.
|
|
24
|
+
//
|
|
25
|
+
// - message `{ type: "og-cache", files }`: files of one variant (absolute
|
|
26
|
+
// URLs); each names its whole variant (the lanes variant includes
|
|
27
|
+
// snippets/), and only the build's own variant files are cached.
|
|
28
|
+
// - install: fetch every precached file into `og-<scope>-<version>`; a failure keeps the old build.
|
|
29
|
+
// CacheStorage is per origin, so the name carries a hash of the registration's scope:
|
|
30
|
+
// builds served side by side on one origin keep their own caches.
|
|
31
|
+
// - activate: delete this scope's other caches (`og-<scope>-*`, and an older build's
|
|
32
|
+
// unscoped `og-<version>` only when it holds this scope's page), take the clients.
|
|
33
|
+
// - fetch: the page (a navigation, index.html) from the network first, so
|
|
34
|
+
// whatever the host adds to it (the Stage proxy's told origin, the
|
|
35
|
+
// platform's `window.__OG_GAME`, the Player's modes) is current, and the
|
|
36
|
+
// cached index.html offline; every other precached GET from the cache; a
|
|
37
|
+
// pack from the cache, or from the network into the cache; anything else
|
|
38
|
+
// from the network. Responses keep their own headers, so COOP/COEP survive
|
|
39
|
+
// offline.
|
|
40
|
+
//
|
|
41
|
+
// A navigation is answered from the browser's own navigation request
|
|
42
|
+
// (navigation preload, enabled on activation), never a copy the worker
|
|
43
|
+
// fetches: a worker's `fetch(event.request)` of a navigation leaves as
|
|
44
|
+
// `Sec-Fetch-Dest: empty`, so a host that answers a document differently
|
|
45
|
+
// from a plain read (the platform serves a release's derived entry, with
|
|
46
|
+
// `window.__OG_GAME`, only to a document or frame load) would hand the
|
|
47
|
+
// revisiting page its stored bytes instead. Only where the browser has no
|
|
48
|
+
// navigation preload does the worker fetch the page itself.
|
|
49
|
+
|
|
50
|
+
declare const OG_SW: {
|
|
51
|
+
readonly version: string;
|
|
52
|
+
readonly files: readonly string[];
|
|
53
|
+
readonly lazy?: readonly string[];
|
|
54
|
+
readonly single?: readonly string[];
|
|
55
|
+
readonly lanes?: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
import { laneChoice } from "../src/web/lanes.ts";
|
|
59
|
+
|
|
60
|
+
interface SwScope {
|
|
61
|
+
readonly registration: { readonly scope: string; readonly navigationPreload?: { enable(): Promise<void> } };
|
|
62
|
+
readonly clients: { claim(): Promise<void> };
|
|
63
|
+
skipWaiting(): Promise<void>;
|
|
64
|
+
addEventListener(type: "install" | "activate", fn: (e: { waitUntil(p: Promise<unknown>): void }) => void): void;
|
|
65
|
+
addEventListener(type: "fetch", fn: (e: { request: Request; preloadResponse?: Promise<Response | undefined>; respondWith(r: Promise<Response>): void }) => void): void;
|
|
66
|
+
addEventListener(type: "message", fn: (e: { data: unknown; waitUntil(p: Promise<unknown>): void }) => void): void;
|
|
67
|
+
}
|
|
68
|
+
const scope = globalThis as unknown as SwScope;
|
|
69
|
+
|
|
70
|
+
/** FNV-1a, 32 bits, as 8 hex digits: a short stable name for the scope, not a secret. */
|
|
71
|
+
function scopeHash(text: string): string {
|
|
72
|
+
let h = 0x811c9dc5;
|
|
73
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
74
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
75
|
+
}
|
|
76
|
+
const SCOPE = `og-${scopeHash(scope.registration.scope)}-`;
|
|
77
|
+
const CACHE = SCOPE + OG_SW.version;
|
|
78
|
+
const base = new URL(scope.registration.scope);
|
|
79
|
+
const variants = { single: OG_SW.single ?? [], lanes: OG_SW.lanes ?? [] };
|
|
80
|
+
const cores = (globalThis as { navigator?: { hardwareConcurrency?: number } }).navigator?.hardwareConcurrency ?? 0;
|
|
81
|
+
const choice = laneChoice(variants.lanes.length > 0, (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true, cores);
|
|
82
|
+
const [mine, other] = "threads" in choice ? [variants.lanes, variants.single] : [variants.single, variants.lanes];
|
|
83
|
+
const urls = new Set([...OG_SW.files, ...mine].map((f) => new URL(f, base).href));
|
|
84
|
+
const lazy = new Set([...(OG_SW.lazy ?? []), ...other].map((f) => new URL(f, base).href));
|
|
85
|
+
const index = new URL("index.html", base).href;
|
|
86
|
+
|
|
87
|
+
scope.addEventListener("install", (e) => {
|
|
88
|
+
e.waitUntil(
|
|
89
|
+
caches
|
|
90
|
+
.open(CACHE)
|
|
91
|
+
.then((cache) => cache.addAll([...urls]))
|
|
92
|
+
.then(() => scope.skipWaiting()),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The variant the page booted, cached whole: each of its files names its variant.
|
|
97
|
+
const variantOf = new Map<string, readonly string[]>();
|
|
98
|
+
for (const files of [variants.single, variants.lanes]) {
|
|
99
|
+
const hrefs = files.map((f) => new URL(f, base).href);
|
|
100
|
+
for (const h of hrefs) variantOf.set(h, hrefs);
|
|
101
|
+
}
|
|
102
|
+
scope.addEventListener("message", (e) => {
|
|
103
|
+
const m = e.data as { type?: unknown; files?: unknown } | null;
|
|
104
|
+
if (!m || m.type !== "og-cache" || !Array.isArray(m.files)) return;
|
|
105
|
+
const wanted = new Set<string>();
|
|
106
|
+
for (const f of m.files) {
|
|
107
|
+
if (typeof f !== "string") continue;
|
|
108
|
+
for (const h of variantOf.get(f) ?? []) wanted.add(h);
|
|
109
|
+
}
|
|
110
|
+
if (wanted.size === 0) return;
|
|
111
|
+
e.waitUntil(
|
|
112
|
+
caches
|
|
113
|
+
.open(CACHE)
|
|
114
|
+
.then(async (cache) => {
|
|
115
|
+
const missing: string[] = [];
|
|
116
|
+
for (const h of wanted) if (!(await cache.match(h))) missing.push(h);
|
|
117
|
+
if (missing.length > 0) await cache.addAll(missing);
|
|
118
|
+
})
|
|
119
|
+
.catch(() => {}),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
scope.addEventListener("activate", (e) => {
|
|
124
|
+
e.waitUntil(
|
|
125
|
+
Promise.all([
|
|
126
|
+
// The browser makes navigations itself (as documents), in parallel with this worker's start.
|
|
127
|
+
scope.registration.navigationPreload?.enable().catch(() => {}),
|
|
128
|
+
caches.keys().then((keys) =>
|
|
129
|
+
Promise.all(
|
|
130
|
+
keys.map(async (k) => {
|
|
131
|
+
if (k === CACHE) return;
|
|
132
|
+
const mine = k.startsWith(SCOPE) || (/^og-[0-9a-f]{16}$/.test(k) && (await (await caches.open(k)).match(base.href)) !== undefined);
|
|
133
|
+
if (mine) await caches.delete(k);
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
]).then(() => scope.clients.claim()),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
scope.addEventListener("fetch", (e) => {
|
|
142
|
+
if (e.request.method !== "GET") return;
|
|
143
|
+
const url = new URL(e.request.url);
|
|
144
|
+
url.search = "";
|
|
145
|
+
url.hash = "";
|
|
146
|
+
const key = url.href === base.href ? index : url.href;
|
|
147
|
+
const page = e.request.mode === "navigate" || key === index;
|
|
148
|
+
if (lazy.has(key)) {
|
|
149
|
+
e.respondWith(
|
|
150
|
+
caches.open(CACHE).then(async (cache) => {
|
|
151
|
+
const hit = await cache.match(key);
|
|
152
|
+
if (hit) return hit;
|
|
153
|
+
const r = await fetch(e.request);
|
|
154
|
+
if (r.ok) await cache.put(key, r.clone());
|
|
155
|
+
return r;
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!urls.has(key) && !page) return;
|
|
161
|
+
e.respondWith(
|
|
162
|
+
caches.open(CACHE).then(async (cache) => {
|
|
163
|
+
// The page from the network first (a host may add to it), the cache offline.
|
|
164
|
+
if (page) {
|
|
165
|
+
try {
|
|
166
|
+
const preloaded = e.request.mode === "navigate" ? await e.preloadResponse : undefined;
|
|
167
|
+
return preloaded ?? (await fetch(e.request));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const hit = await cache.match(index);
|
|
170
|
+
if (hit) return hit;
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return (await cache.match(key)) ?? fetch(e.request);
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// The Player protocol 2 over same-origin HTTP: the transport of a top-level
|
|
2
|
+
// engine page on its play origin (origingame/docs/player-protocol.md §The
|
|
3
|
+
// top-level engine page). The page is the play page itself, so it does what
|
|
4
|
+
// the framed Player did around it: it keeps the game-scoped credential
|
|
5
|
+
// (redeeming the Portal's hand-off code from the fragment), sends each call
|
|
6
|
+
// as a same-origin request, keeps the offline outbox, and reports the
|
|
7
|
+
// session's lifecycle (start, ready, samples, the game's events, heartbeats,
|
|
8
|
+
// the end) for the play origin to file.
|
|
9
|
+
//
|
|
10
|
+
// POST /__player/v2/<game>/<release>/hello the protocol-2 hello (+ handoff) → the Player's hello, player, page
|
|
11
|
+
// POST /__player/v2/<game>/<release>/call a protocol-2 call → its result (+ player)
|
|
12
|
+
// POST /__player/v2/<game>/<release>/report { session, device, messages } → 202 { accepted, rejected } (+ player)
|
|
13
|
+
//
|
|
14
|
+
// One request at a time (calls and reports share the queue: a page mints at
|
|
15
|
+
// most one guest); 15 s without an answer is `timeout`, a failed fetch
|
|
16
|
+
// `offline`. The credential travels only as `Authorization: Bearer` on these
|
|
17
|
+
// requests; it is kept in memory, and a guest's also under the Player's
|
|
18
|
+
// `og_token_<game>` key. Nothing here touches og.platform's own storage.
|
|
19
|
+
|
|
20
|
+
import { fingerprint, timeOf, type PlayerEvents, type PlayerResult } from "./player.ts";
|
|
21
|
+
|
|
22
|
+
/** `window.__OG_GAME`, written into the entry at publication. */
|
|
23
|
+
export interface PageGame {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly releaseId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the Player page used to draw around the game, for the page to draw itself. */
|
|
29
|
+
export interface PageInfo {
|
|
30
|
+
readonly title: string | null;
|
|
31
|
+
readonly cover: string | null;
|
|
32
|
+
readonly backdrop: string | null;
|
|
33
|
+
readonly back: string | null;
|
|
34
|
+
readonly portal: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StorageLike {
|
|
38
|
+
getItem(key: string): string | null;
|
|
39
|
+
setItem(key: string, value: string): void;
|
|
40
|
+
removeItem(key: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TransportEnv {
|
|
44
|
+
readonly fetch: (url: string, init: RequestInit) => Promise<Response>;
|
|
45
|
+
readonly storage: StorageLike | null;
|
|
46
|
+
/** The page's address: the fragment is read, and the hand-off code removed from it. */
|
|
47
|
+
readonly location: { readonly hash: string; readonly pathname: string; readonly search: string };
|
|
48
|
+
readonly replaceUrl: (url: string) => void;
|
|
49
|
+
readonly now: () => number;
|
|
50
|
+
/** ms since the page's navigation start. */
|
|
51
|
+
readonly clock: () => number;
|
|
52
|
+
readonly random: (length: number) => Uint8Array;
|
|
53
|
+
readonly setTimer?: (callback: () => void, ms: number) => unknown;
|
|
54
|
+
readonly clearTimer?: (handle: unknown) => void;
|
|
55
|
+
readonly locale?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How long a request waits for its answer before it is `timeout`. */
|
|
59
|
+
export const PAGE_CALL_TIMEOUT_MS = 15_000;
|
|
60
|
+
/** Messages a page keeps unsent at most, and sends per report. */
|
|
61
|
+
export const REPORT_KEEP = 200;
|
|
62
|
+
export const REPORT_BATCH = 20;
|
|
63
|
+
/** A report's body at most, in UTF-8 bytes: the play origin's limit, and the browser's for a keepalive request in flight. */
|
|
64
|
+
export const REPORT_MAX_BYTES = 64 * 1024;
|
|
65
|
+
/** How long a save or score stays in the outbox. */
|
|
66
|
+
const OUTBOX_DAYS = 7;
|
|
67
|
+
const RETRYABLE = new Set(["offline", "timeout", "unavailable", "rate_limited"]);
|
|
68
|
+
|
|
69
|
+
/** The page's `window.__OG_GAME`, when it names a game and release that speak protocol 2. */
|
|
70
|
+
export function pageGame(value: unknown): PageGame | null {
|
|
71
|
+
const g = value as { id?: unknown; releaseId?: unknown; protocol?: unknown } | null;
|
|
72
|
+
if (!g || typeof g !== "object" || g.protocol !== 2) return null;
|
|
73
|
+
if (typeof g.id !== "string" || !/^[a-z0-9]{6,32}$/.test(g.id)) return null;
|
|
74
|
+
if (typeof g.releaseId !== "string" || !/^release_[A-Za-z0-9_-]{1,80}$/.test(g.releaseId)) return null;
|
|
75
|
+
return { id: g.id, releaseId: g.releaseId };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Splits the Portal's one-time hand-off code (`og-h=<code>`) off a fragment; the rest stays the game's. */
|
|
79
|
+
export function takeHandoff(hash: string): { code: string | null; hash: string } {
|
|
80
|
+
const parts = hash.replace(/^#/, "").split("&");
|
|
81
|
+
let code: string | null = null;
|
|
82
|
+
const rest = parts.filter((part) => {
|
|
83
|
+
if (!part.startsWith("og-h=")) return true;
|
|
84
|
+
const value = part.slice(5);
|
|
85
|
+
if (/^[A-Za-z0-9_-]{16,256}$/.test(value)) code = value;
|
|
86
|
+
return false;
|
|
87
|
+
});
|
|
88
|
+
if (rest.length === parts.length) return { code: null, hash };
|
|
89
|
+
const joined = rest.filter(Boolean).join("&");
|
|
90
|
+
return { code, hash: joined ? `#${joined}` : "" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A failed request's status as protocol 2's error (the platform's `playerWireError` table). */
|
|
94
|
+
export function statusError(status: number, message: string, revision?: number): PlayerResult {
|
|
95
|
+
const code =
|
|
96
|
+
status === 0 ? "offline" : status < 0 ? "timeout" : status >= 500 ? "unavailable"
|
|
97
|
+
: ({ 400: "invalid", 422: "invalid", 401: "unauthorized", 403: "unauthorized", 404: "not_found", 409: "conflict", 413: "too_large", 429: "rate_limited" } as Record<number, string>)[status] ?? "failed";
|
|
98
|
+
return { ok: false, error: { code, message, retryable: RETRYABLE.has(code), ...(status > 0 ? { status } : {}), ...(typeof revision === "number" ? { revision } : {}) } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
102
|
+
|
|
103
|
+
/** A UUID (version 7: time-ordered), the id a report message is filed under. */
|
|
104
|
+
export function uuid7(random: (length: number) => Uint8Array, now: number): string {
|
|
105
|
+
const b = random(16);
|
|
106
|
+
for (let i = 5; i >= 0; i--) b[i] = Math.floor(now / 2 ** ((5 - i) * 8)) & 0xff;
|
|
107
|
+
b[6] = (b[6]! & 0x0f) | 0x70;
|
|
108
|
+
b[8] = (b[8]! & 0x3f) | 0x80;
|
|
109
|
+
const h = hex(b);
|
|
110
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The Player's per-game device id on this browser (`og_device_<game>`, 30 days). */
|
|
114
|
+
export function deviceId(storage: StorageLike | null, game: string, now: number, random: (length: number) => Uint8Array): string {
|
|
115
|
+
const key = `og_device_${game}`;
|
|
116
|
+
try {
|
|
117
|
+
const d = JSON.parse(storage?.getItem(key) ?? "null") as { id?: unknown; createdAt?: unknown } | null;
|
|
118
|
+
if (typeof d?.id === "string" && /^device_[0-9a-f]{32}$/.test(d.id) && typeof d.createdAt === "number" && d.createdAt <= now && now - d.createdAt < 30 * 864e5) return d.id;
|
|
119
|
+
} catch {
|
|
120
|
+
/* replaced below */
|
|
121
|
+
}
|
|
122
|
+
const id = `device_${hex(random(16))}`;
|
|
123
|
+
try {
|
|
124
|
+
storage?.setItem(key, JSON.stringify({ id, createdAt: now }));
|
|
125
|
+
} catch {
|
|
126
|
+
/* private mode */
|
|
127
|
+
}
|
|
128
|
+
return id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Outbox = { save?: { data: unknown }; scores: Array<Record<string, unknown>>; at: number };
|
|
132
|
+
type Message = Record<string, unknown> & { kind: string };
|
|
133
|
+
|
|
134
|
+
export interface PageConnection {
|
|
135
|
+
readonly transport: "page";
|
|
136
|
+
readonly framed: false;
|
|
137
|
+
/** Whether the play origin answered the hello. */
|
|
138
|
+
readonly hosted: boolean;
|
|
139
|
+
readonly started: boolean;
|
|
140
|
+
readonly paused: boolean;
|
|
141
|
+
readonly capabilities: readonly string[];
|
|
142
|
+
readonly page: PageInfo | null;
|
|
143
|
+
/** Resolves once the hello is answered (true) or failed (false). */
|
|
144
|
+
readonly hello: Promise<boolean>;
|
|
145
|
+
loading(value: number | null, label?: string): void;
|
|
146
|
+
ready(): void;
|
|
147
|
+
fail(code: string, message: string, retryable: boolean): void;
|
|
148
|
+
/** After ready: the game stopped (only an error name and a fingerprint of the message leave the page). */
|
|
149
|
+
crash(name: string, message: string): void;
|
|
150
|
+
sample(fields: Record<string, unknown>): void;
|
|
151
|
+
event(name: string, attrs?: Record<string, string | number | boolean | null>): void;
|
|
152
|
+
request(method: string, args?: Record<string, unknown>): Promise<PlayerResult>;
|
|
153
|
+
/** The person started play (the page's own gate): the session opens. */
|
|
154
|
+
start(): void;
|
|
155
|
+
/** The page is going away: the end report, sent with keepalive. */
|
|
156
|
+
end(state: { ready: boolean; failed: boolean }): void;
|
|
157
|
+
/** Sends what is queued (tests; the page flushes by itself). */
|
|
158
|
+
flush(): Promise<void>;
|
|
159
|
+
/** The credential this page holds now (tests). */
|
|
160
|
+
readonly credential: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The HTTP transport for `game`, under its play origin's `/__player/v2/<game>/<release>/`. */
|
|
164
|
+
export function connectPage(game: PageGame, env: TransportEnv, events: PlayerEvents = {}): PageConnection {
|
|
165
|
+
const base = `/__player/v2/${game.id}/${game.releaseId}/`;
|
|
166
|
+
const tokenKey = `og_token_${game.id}`;
|
|
167
|
+
const portalKey = `og_portal_${game.id}`;
|
|
168
|
+
const outboxKey = `og_outbox_${game.id}`;
|
|
169
|
+
const store = env.storage;
|
|
170
|
+
const setTimer = env.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
171
|
+
const clearTimer = env.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
172
|
+
const get = (k: string) => {
|
|
173
|
+
try {
|
|
174
|
+
return store?.getItem(k) ?? null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const set = (k: string, v: string | null) => {
|
|
180
|
+
try {
|
|
181
|
+
if (v === null) store?.removeItem(k);
|
|
182
|
+
else store?.setItem(k, v);
|
|
183
|
+
} catch {
|
|
184
|
+
/* private mode: memory keeps it */
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ── identity: the fragment's code, else a stored guest, else none ──
|
|
189
|
+
const handoff = takeHandoff(env.location.hash);
|
|
190
|
+
if (handoff.hash !== env.location.hash) env.replaceUrl(env.location.pathname + env.location.search + handoff.hash);
|
|
191
|
+
let token: string | null = null;
|
|
192
|
+
if (handoff.code) {
|
|
193
|
+
// An attempted hand-off never leaves the previous account (or guest) available.
|
|
194
|
+
set(tokenKey, null);
|
|
195
|
+
set(portalKey, null);
|
|
196
|
+
} else if (get(portalKey)) {
|
|
197
|
+
// An account is resumed only by a fresh Portal code.
|
|
198
|
+
set(tokenKey, null);
|
|
199
|
+
set(portalKey, null);
|
|
200
|
+
} else token = get(tokenKey);
|
|
201
|
+
const adopt = (p: unknown) => {
|
|
202
|
+
const player = p as { token?: unknown; guest?: unknown } | null | undefined;
|
|
203
|
+
if (!player || typeof player.token !== "string" || !player.token) return;
|
|
204
|
+
token = player.token;
|
|
205
|
+
if (player.guest === false) {
|
|
206
|
+
set(tokenKey, null);
|
|
207
|
+
set(portalKey, "1");
|
|
208
|
+
} else {
|
|
209
|
+
set(tokenKey, player.token);
|
|
210
|
+
set(portalKey, null);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ── one request at a time ──
|
|
215
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
216
|
+
const serial = <T>(task: () => Promise<T>): Promise<T> => {
|
|
217
|
+
const next = chain.then(task, task);
|
|
218
|
+
chain = next.catch(() => {});
|
|
219
|
+
return next;
|
|
220
|
+
};
|
|
221
|
+
/** One POST: its status and JSON body; status 0 offline, -1 timed out. */
|
|
222
|
+
const post = async (what: string, body: unknown, keepalive = false): Promise<{ status: number; data: Record<string, unknown> | null }> => {
|
|
223
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
224
|
+
let timedOut = false;
|
|
225
|
+
const timer = setTimer(() => {
|
|
226
|
+
timedOut = true;
|
|
227
|
+
controller?.abort();
|
|
228
|
+
}, PAGE_CALL_TIMEOUT_MS);
|
|
229
|
+
try {
|
|
230
|
+
const response = await env.fetch(base + what, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
credentials: "omit",
|
|
233
|
+
cache: "no-store",
|
|
234
|
+
keepalive,
|
|
235
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
236
|
+
body: JSON.stringify(body),
|
|
237
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
238
|
+
});
|
|
239
|
+
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
|
240
|
+
if (data && typeof data === "object") adopt(data["player"]);
|
|
241
|
+
return { status: response.status, data };
|
|
242
|
+
} catch {
|
|
243
|
+
return { status: timedOut ? -1 : 0, data: null };
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimer(timer);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// ── hello ──
|
|
250
|
+
let hosted = false;
|
|
251
|
+
let capabilities: readonly string[] = [];
|
|
252
|
+
let page: PageInfo | null = null;
|
|
253
|
+
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
|
254
|
+
const hello = serial(async () => {
|
|
255
|
+
const r = await post("hello", { og: "player", protocol: 2, kind: "hello", substrate: "origin-engine", ...(handoff.code ? { handoff: handoff.code } : {}) });
|
|
256
|
+
const m = r.data;
|
|
257
|
+
if (r.status !== 200 || !m || m["kind"] !== "hello") return false;
|
|
258
|
+
hosted = true;
|
|
259
|
+
capabilities = Array.isArray(m["capabilities"]) ? (m["capabilities"] as unknown[]).filter((c): c is string => typeof c === "string") : [];
|
|
260
|
+
const p = (m["page"] ?? null) as Record<string, unknown> | null;
|
|
261
|
+
page = p && typeof p === "object" ? { title: str(p["title"]), cover: str(p["cover"]), backdrop: str(p["backdrop"]), back: str(p["back"]), portal: str(p["portal"]) } : null;
|
|
262
|
+
events.hello?.({ started: false, paused: false, capabilities });
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── reports: nothing before start ──
|
|
267
|
+
const session = `game_session_${hex(env.random(16))}`;
|
|
268
|
+
const device = deviceId(store, game.id, env.now(), env.random);
|
|
269
|
+
let started = false;
|
|
270
|
+
let held: Message[] = [];
|
|
271
|
+
let unsent: Message[] = [];
|
|
272
|
+
let flushTimer: unknown = null;
|
|
273
|
+
let readySent = false;
|
|
274
|
+
let failed = false;
|
|
275
|
+
let lastValue = -1;
|
|
276
|
+
let startedAt = 0;
|
|
277
|
+
let heartbeat: unknown = null;
|
|
278
|
+
const stamp = (m: Message): Message => ({ og: "player", protocol: 2, ...m, id: uuid7(env.random, env.now()), at: env.now(), ms: Math.round(env.clock()) });
|
|
279
|
+
const keep = (m: Message) => {
|
|
280
|
+
unsent.push(m);
|
|
281
|
+
if (unsent.length > REPORT_KEEP) unsent.splice(0, unsent.length - REPORT_KEEP);
|
|
282
|
+
};
|
|
283
|
+
const report = (m: Message) => {
|
|
284
|
+
const s = stamp(m);
|
|
285
|
+
if (!started) {
|
|
286
|
+
held.push(s);
|
|
287
|
+
if (held.length > REPORT_KEEP) held.splice(0, held.length - REPORT_KEEP);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
keep(s);
|
|
291
|
+
schedule();
|
|
292
|
+
};
|
|
293
|
+
const schedule = (ms = 1000) => {
|
|
294
|
+
if (flushTimer !== null) return;
|
|
295
|
+
flushTimer = setTimer(() => {
|
|
296
|
+
flushTimer = null;
|
|
297
|
+
void flush();
|
|
298
|
+
}, ms);
|
|
299
|
+
};
|
|
300
|
+
const encoder = new TextEncoder();
|
|
301
|
+
const bytes = (v: unknown) => encoder.encode(JSON.stringify(v)).length;
|
|
302
|
+
/** At most `count` messages from the front (or the end) whose report fits REPORT_MAX_BYTES; always at least one. */
|
|
303
|
+
const batchOf = (messages: readonly Message[], count: number, fromEnd = false): Message[] => {
|
|
304
|
+
const order = fromEnd ? [...messages].reverse() : messages;
|
|
305
|
+
let size = bytes({ session, device, messages: [] });
|
|
306
|
+
const batch: Message[] = [];
|
|
307
|
+
for (const m of order) {
|
|
308
|
+
if (batch.length >= count) break;
|
|
309
|
+
const more = bytes(m) + (batch.length > 0 ? 1 : 0);
|
|
310
|
+
if (batch.length > 0 && size + more > REPORT_MAX_BYTES) break;
|
|
311
|
+
size += more;
|
|
312
|
+
batch.push(m);
|
|
313
|
+
}
|
|
314
|
+
return fromEnd ? batch.reverse() : batch;
|
|
315
|
+
};
|
|
316
|
+
let flushing: Promise<void> | null = null;
|
|
317
|
+
const flush = (): Promise<void> => {
|
|
318
|
+
if (flushing) return flushing;
|
|
319
|
+
flushing = serial(async () => {
|
|
320
|
+
let count = REPORT_BATCH;
|
|
321
|
+
while (unsent.length) {
|
|
322
|
+
const batch = batchOf(unsent, count);
|
|
323
|
+
const r = await post("report", { session, device, messages: batch });
|
|
324
|
+
// Too large: halve the batch; a single message that still does not fit is dropped below.
|
|
325
|
+
if (r.status === 413 && batch.length > 1) {
|
|
326
|
+
count = Math.ceil(batch.length / 2);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// 400 (invalid) and 413 are final: sending the same batch again fails the same way, so it is dropped.
|
|
330
|
+
if (r.status !== 202 && r.status !== 200 && r.status !== 400 && r.status !== 413) return; // kept for the next report
|
|
331
|
+
unsent = unsent.filter((m) => !batch.includes(m));
|
|
332
|
+
}
|
|
333
|
+
}).finally(() => (flushing = null));
|
|
334
|
+
return flushing;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// ── the outbox (the Player's key and shape) ──
|
|
338
|
+
const readBox = (): Outbox => {
|
|
339
|
+
try {
|
|
340
|
+
const b = JSON.parse(get(outboxKey) ?? "null") as Outbox | null;
|
|
341
|
+
if (b && Array.isArray(b.scores) && env.now() - b.at < OUTBOX_DAYS * 864e5) return b;
|
|
342
|
+
} catch {
|
|
343
|
+
/* corrupted */
|
|
344
|
+
}
|
|
345
|
+
return { scores: [], at: env.now() };
|
|
346
|
+
};
|
|
347
|
+
const writeBox = (b: Outbox) => set(outboxKey, b.save || b.scores.length ? JSON.stringify({ ...b, at: env.now() }) : null);
|
|
348
|
+
|
|
349
|
+
/** One call as its protocol-2 result. */
|
|
350
|
+
let nextCall = 1;
|
|
351
|
+
const call = (method: string, args: Record<string, unknown>): Promise<PlayerResult> =>
|
|
352
|
+
serial(async () => {
|
|
353
|
+
const id = `oe-${nextCall++}`;
|
|
354
|
+
const r = await post("call", { og: "player", protocol: 2, kind: "call", id, method, args });
|
|
355
|
+
const m = r.data;
|
|
356
|
+
if (m && m["kind"] === "result" && m["id"] === id) {
|
|
357
|
+
if (m["ok"] === true) return { ok: true, data: m["data"] ?? null, ...timeOf(m) };
|
|
358
|
+
const e = (m["error"] ?? {}) as Record<string, unknown>;
|
|
359
|
+
const code = typeof e["code"] === "string" ? e["code"] : "failed";
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: {
|
|
363
|
+
code,
|
|
364
|
+
message: typeof e["message"] === "string" ? e["message"] : "",
|
|
365
|
+
retryable: e["retryable"] === true,
|
|
366
|
+
...(typeof e["status"] === "number" ? { status: e["status"] } : {}),
|
|
367
|
+
...(typeof e["revision"] === "number" ? { revision: e["revision"] } : {}),
|
|
368
|
+
},
|
|
369
|
+
...timeOf(m),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return statusError(r.status, r.status === 0 ? "the network is unreachable" : r.status < 0 ? `no answer to ${method} in ${PAGE_CALL_TIMEOUT_MS} ms` : `the play origin answered ${r.status}`);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
let delivering = false;
|
|
376
|
+
/** Delivers the outbox in order: the latest save, then the scores. */
|
|
377
|
+
const deliver = async (): Promise<void> => {
|
|
378
|
+
if (delivering) return;
|
|
379
|
+
delivering = true;
|
|
380
|
+
try {
|
|
381
|
+
let box = readBox();
|
|
382
|
+
if (box.save) {
|
|
383
|
+
const r = await call("save.set", { data: box.save.data });
|
|
384
|
+
if (!r.ok && r.error.retryable) return;
|
|
385
|
+
box = readBox();
|
|
386
|
+
delete box.save;
|
|
387
|
+
writeBox(box);
|
|
388
|
+
}
|
|
389
|
+
for (box = readBox(); box.scores.length; box = readBox()) {
|
|
390
|
+
const r = await call("leaderboard.submit", { ...box.scores[0] });
|
|
391
|
+
if (!r.ok && r.error.retryable) return;
|
|
392
|
+
box.scores.shift();
|
|
393
|
+
writeBox(box);
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
delivering = false;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
void hello.then(() => {
|
|
400
|
+
const b = readBox();
|
|
401
|
+
if (b.save || b.scores.length) void deliver();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const request = async (method: string, args: Record<string, unknown> = {}): Promise<PlayerResult> => {
|
|
405
|
+
await hello;
|
|
406
|
+
const queueable = method === "leaderboard.submit" || (method === "save.set" && (args["revision"] === undefined || args["revision"] === null));
|
|
407
|
+
if (method === "save.get" || method === "save.read") {
|
|
408
|
+
const pending = readBox().save;
|
|
409
|
+
if (pending && method === "save.get") return { ok: true, data: pending.data };
|
|
410
|
+
if (pending) {
|
|
411
|
+
await deliver();
|
|
412
|
+
const still = readBox().save;
|
|
413
|
+
if (still) return { ok: true, data: { data: still.data, revision: null, queued: true } };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (method === "save.set" && queueable) {
|
|
417
|
+
// The latest save wins: a queued older one is dropped first.
|
|
418
|
+
const b = readBox();
|
|
419
|
+
delete b.save;
|
|
420
|
+
writeBox(b);
|
|
421
|
+
}
|
|
422
|
+
const r = await call(method, args);
|
|
423
|
+
if (r.ok) {
|
|
424
|
+
const b = readBox();
|
|
425
|
+
if (b.save || b.scores.length) void deliver();
|
|
426
|
+
return r;
|
|
427
|
+
}
|
|
428
|
+
if (!queueable || !r.error.retryable) return r;
|
|
429
|
+
const b = readBox();
|
|
430
|
+
if (method === "save.set") b.save = { data: args["data"] ?? null };
|
|
431
|
+
else b.scores = [...b.scores, { score: args["score"], ...(args["board"] === undefined ? {} : { board: args["board"] }), ...(args["order"] === undefined ? {} : { order: args["order"] }) }].slice(-20);
|
|
432
|
+
writeBox(b);
|
|
433
|
+
return { ok: true, data: method === "save.set" ? { ok: true, queued: true } : { ok: true, rank: null, queued: true } };
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
transport: "page",
|
|
438
|
+
framed: false,
|
|
439
|
+
get hosted() {
|
|
440
|
+
return hosted;
|
|
441
|
+
},
|
|
442
|
+
get started() {
|
|
443
|
+
return started;
|
|
444
|
+
},
|
|
445
|
+
paused: false,
|
|
446
|
+
get capabilities() {
|
|
447
|
+
return capabilities;
|
|
448
|
+
},
|
|
449
|
+
get page() {
|
|
450
|
+
return page;
|
|
451
|
+
},
|
|
452
|
+
hello,
|
|
453
|
+
get credential() {
|
|
454
|
+
return token;
|
|
455
|
+
},
|
|
456
|
+
loading(value) {
|
|
457
|
+
if (readySent || value === null) return;
|
|
458
|
+
const v = Math.round(Math.min(1, Math.max(0, value)) * 1000) / 1000;
|
|
459
|
+
if (v <= lastValue) return;
|
|
460
|
+
lastValue = v;
|
|
461
|
+
// Accepted and not filed by the play origin: not worth a request each.
|
|
462
|
+
},
|
|
463
|
+
ready() {
|
|
464
|
+
if (readySent || failed) return;
|
|
465
|
+
readySent = true;
|
|
466
|
+
report({ kind: "ready" });
|
|
467
|
+
},
|
|
468
|
+
fail(code, message, retryable) {
|
|
469
|
+
if (readySent || failed) return;
|
|
470
|
+
failed = true;
|
|
471
|
+
report({ kind: "fail", code, message: message.slice(0, 400), retryable });
|
|
472
|
+
},
|
|
473
|
+
crash(name, message) {
|
|
474
|
+
failed = true;
|
|
475
|
+
report({ kind: "crash", name: /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name) ? name : "Error", fingerprint: fingerprint(message) });
|
|
476
|
+
},
|
|
477
|
+
sample(fields) {
|
|
478
|
+
report({ kind: "sample", substrate: "origin-engine", ...fields });
|
|
479
|
+
},
|
|
480
|
+
event(name, attrs) {
|
|
481
|
+
report({ kind: "event", name, ...(attrs ? { attrs } : {}) });
|
|
482
|
+
},
|
|
483
|
+
request,
|
|
484
|
+
start() {
|
|
485
|
+
if (started) return;
|
|
486
|
+
started = true;
|
|
487
|
+
startedAt = env.now();
|
|
488
|
+
const first = stamp({ kind: "start" });
|
|
489
|
+
keep(first);
|
|
490
|
+
for (const m of held.splice(0)) keep(m);
|
|
491
|
+
void flush();
|
|
492
|
+
const beat = () => {
|
|
493
|
+
heartbeat = setTimer(() => {
|
|
494
|
+
if (readySent) {
|
|
495
|
+
keep(stamp({ kind: "heartbeat", durationMs: env.now() - startedAt }));
|
|
496
|
+
void flush();
|
|
497
|
+
}
|
|
498
|
+
beat();
|
|
499
|
+
}, 60_000);
|
|
500
|
+
};
|
|
501
|
+
beat();
|
|
502
|
+
},
|
|
503
|
+
end(state) {
|
|
504
|
+
if (heartbeat !== null) clearTimer(heartbeat);
|
|
505
|
+
heartbeat = null;
|
|
506
|
+
if (!started) return;
|
|
507
|
+
keep(stamp({ kind: "end", durationMs: env.now() - startedAt, ready: state.ready, failed: state.failed }));
|
|
508
|
+
// The page is going: no queue to wait behind, keepalive so the request outlives it.
|
|
509
|
+
const batch = batchOf(unsent, REPORT_BATCH, true);
|
|
510
|
+
unsent = [];
|
|
511
|
+
void post("report", { session, device, messages: batch }, true);
|
|
512
|
+
},
|
|
513
|
+
flush,
|
|
514
|
+
};
|
|
515
|
+
}
|