@krtuworks/sdk 0.0.0-stage → 1.0.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/AGENTS.md +48 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +24 -2
- package/dist/api.d.ts +34 -0
- package/dist/api.js +61 -0
- package/dist/avatarReports.d.ts +15 -0
- package/dist/avatarReports.js +18 -0
- package/dist/bugs.d.ts +114 -0
- package/dist/bugs.js +276 -0
- package/dist/client.d.ts +390 -0
- package/dist/client.js +666 -0
- package/dist/clock.d.ts +19 -0
- package/dist/clock.js +32 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +68 -0
- package/dist/deviceKey.d.ts +32 -0
- package/dist/deviceKey.js +26 -0
- package/dist/entitlements.d.ts +48 -0
- package/dist/entitlements.js +113 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +32 -0
- package/dist/events.d.ts +93 -0
- package/dist/events.js +304 -0
- package/dist/families.d.ts +94 -0
- package/dist/families.js +1 -0
- package/dist/files.d.ts +108 -0
- package/dist/files.js +215 -0
- package/dist/identity.d.ts +129 -0
- package/dist/identity.js +437 -0
- package/dist/ids.d.ts +13 -0
- package/dist/ids.js +61 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +7 -0
- package/dist/jwt.d.ts +2 -0
- package/dist/jwt.js +8 -0
- package/dist/locks.d.ts +6 -0
- package/dist/locks.js +20 -0
- package/dist/presence.d.ts +45 -0
- package/dist/presence.js +97 -0
- package/dist/progress.d.ts +108 -0
- package/dist/progress.js +347 -0
- package/dist/progression.d.ts +39 -0
- package/dist/progression.js +81 -0
- package/dist/remoteConfig.d.ts +48 -0
- package/dist/remoteConfig.js +70 -0
- package/dist/sessions.d.ts +45 -0
- package/dist/sessions.js +157 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.js +57 -0
- package/dist/transport.d.ts +30 -0
- package/dist/transport.js +73 -0
- package/dist/validator.d.ts +43 -0
- package/dist/validator.js +252 -0
- package/package.json +40 -4
package/dist/bugs.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { KrtuworksError, isKrtuworksError } from './errors.js';
|
|
2
|
+
import { uuidV4 } from './ids.js';
|
|
3
|
+
/** The fixed categories: the same menu in every app. */
|
|
4
|
+
export const BUG_CATEGORIES = ['CRASH', 'GAMEPLAY', 'GRAPHICS', 'AUDIO', 'PERFORMANCE', 'CONTROLS', 'TEXT', 'OTHER'];
|
|
5
|
+
const MAX_DESCRIPTION = 4000;
|
|
6
|
+
const MAX_SCREENSHOT = 5 * 1024 * 1024;
|
|
7
|
+
const MAX_WAITING = 20;
|
|
8
|
+
const RING_EVENTS = 50;
|
|
9
|
+
const EVENT_CHARS = 1800;
|
|
10
|
+
/** The server takes at most 100 KB of events; the SDK's own part stays under it so it never costs the player's report. */
|
|
11
|
+
const EVENTS_CHARS = 90_000;
|
|
12
|
+
const LOG_CHARS = 32_768;
|
|
13
|
+
const LOG_LINE = 1000;
|
|
14
|
+
const RETRY_MS = 60_000;
|
|
15
|
+
/** The report can never be taken as it is: dropped rather than sent forever. */
|
|
16
|
+
const REPORT_REFUSED = new Set([400, 413]);
|
|
17
|
+
const REPORT_REFUSED_CODES = new Set(['report_id_reused']);
|
|
18
|
+
/** The screenshot can never be taken (too late, too big, not a picture, the report gone); `screenshot_exists` is done. */
|
|
19
|
+
const SCREENSHOT_REFUSED = new Set([400, 404, 409, 411, 413, 415]);
|
|
20
|
+
const TOLD_KEPT = 200;
|
|
21
|
+
/** The picture's type from its first bytes (what the server takes). */
|
|
22
|
+
export function screenshotType(bytes) {
|
|
23
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
24
|
+
return 'image/png';
|
|
25
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
26
|
+
return 'image/jpeg';
|
|
27
|
+
const ascii = (from, to) => String.fromCharCode(...bytes.subarray(from, to));
|
|
28
|
+
if (bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP')
|
|
29
|
+
return 'image/webp';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function refused(message) {
|
|
33
|
+
return new KrtuworksError('invalid_request', message, 400);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The body the server takes for a report: the player's words and what the SDK knows of the moment (the
|
|
37
|
+
* platform, build, device, language, level, place, frame rate, the last events and log lines).
|
|
38
|
+
* Checked here so a report that can never be taken is refused at once instead of waiting in the queue.
|
|
39
|
+
*/
|
|
40
|
+
export function reportBody(input, context) {
|
|
41
|
+
if (!BUG_CATEGORIES.includes(input.category))
|
|
42
|
+
throw refused(`category is one of ${BUG_CATEGORIES.join(', ')}`);
|
|
43
|
+
const description = typeof input.description === 'string' ? input.description.trim() : '';
|
|
44
|
+
if (!description || description.length > MAX_DESCRIPTION)
|
|
45
|
+
throw refused(`description is what the player wrote, 1-${MAX_DESCRIPTION} characters`);
|
|
46
|
+
// The level the SDK saw is cut to fit; one the game names must fit.
|
|
47
|
+
const level = input.level ?? (context.level ? context.level.slice(0, 200) : null);
|
|
48
|
+
if (level !== null && (typeof level !== 'string' || !level.trim() || level.length > 200))
|
|
49
|
+
throw refused('level is 1-200 characters');
|
|
50
|
+
const fps = input.fps ?? context.fps;
|
|
51
|
+
if (fps !== null && (typeof fps !== 'number' || !Number.isFinite(fps) || fps < 0 || fps > 1000))
|
|
52
|
+
throw refused('fps is a number from 0 to 1000');
|
|
53
|
+
const position = input.position;
|
|
54
|
+
if (position && (typeof position.area !== 'string' || !position.area || position.area.length > 100 || !Number.isFinite(position.x) || !Number.isFinite(position.y))) {
|
|
55
|
+
throw refused('position is {area, x, y, z?}');
|
|
56
|
+
}
|
|
57
|
+
let events = context.events;
|
|
58
|
+
while (events.length && JSON.stringify(events).length > EVENTS_CHARS)
|
|
59
|
+
events = events.slice(1);
|
|
60
|
+
const device = context.opts.device ? Object.fromEntries(Object.entries(context.opts.device).filter(([, v]) => v !== undefined && v !== null)) : null;
|
|
61
|
+
return {
|
|
62
|
+
reportId: context.reportId,
|
|
63
|
+
category: input.category,
|
|
64
|
+
description,
|
|
65
|
+
platform: context.opts.platform,
|
|
66
|
+
build: context.opts.build,
|
|
67
|
+
...(device && Object.keys(device).length ? { device } : {}),
|
|
68
|
+
...(context.opts.locale ? { locale: context.opts.locale } : {}),
|
|
69
|
+
...(level ? { level: level.trim() } : {}),
|
|
70
|
+
...(position ? { position: { area: position.area, x: position.x, y: position.y, ...(position.z !== undefined ? { z: position.z } : {}) } } : {}),
|
|
71
|
+
...(fps !== null ? { fps: Math.round(fps * 10) / 10 } : {}),
|
|
72
|
+
...(events.length ? { events } : {}),
|
|
73
|
+
...(context.log ? { log: context.log } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The reports a player told about since the last check: the first check of a player on this device
|
|
78
|
+
* tells nothing (it only learns what was already fixed); a report made here starts the record empty,
|
|
79
|
+
* so its fix is told even when another device saw it first.
|
|
80
|
+
*/
|
|
81
|
+
export function newlyFixed(reports, state) {
|
|
82
|
+
const fixedIds = reports.filter((r) => r.status === 'RESOLVED').map((r) => r.id);
|
|
83
|
+
if (!state)
|
|
84
|
+
return { fixed: [], state: { told: fixedIds.slice(0, TOLD_KEPT) } };
|
|
85
|
+
const told = new Set(state.told);
|
|
86
|
+
const fixed = reports.filter((r) => r.status === 'RESOLVED' && !told.has(r.id));
|
|
87
|
+
return { fixed, state: { told: [...new Set([...fixedIds, ...state.told])].slice(0, TOLD_KEPT) } };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Bug reports from inside the app: a report is kept on the device first and
|
|
91
|
+
* sent when it can be (offline, before the player exists, after a merge), its screenshot right after
|
|
92
|
+
* it. The server takes a reportId once; the body is frozen when the report is made, so sending it again
|
|
93
|
+
* after a lost answer gets the same report. The last events and log lines are kept in memory only.
|
|
94
|
+
*/
|
|
95
|
+
export class BugReports {
|
|
96
|
+
opts;
|
|
97
|
+
store;
|
|
98
|
+
api;
|
|
99
|
+
owner;
|
|
100
|
+
subject;
|
|
101
|
+
events = [];
|
|
102
|
+
logLines = [];
|
|
103
|
+
logChars = 0;
|
|
104
|
+
lastFps = null;
|
|
105
|
+
/** Reports that wait before their next try (in memory: a new launch tries them all again). */
|
|
106
|
+
waitUntil = new Map();
|
|
107
|
+
fixedListeners = new Set();
|
|
108
|
+
constructor(opts, store, api, owner, subject) {
|
|
109
|
+
this.opts = opts;
|
|
110
|
+
this.store = store;
|
|
111
|
+
this.api = api;
|
|
112
|
+
this.owner = owner;
|
|
113
|
+
this.subject = subject;
|
|
114
|
+
}
|
|
115
|
+
prefix(owner) {
|
|
116
|
+
return `krtu:${this.opts.clientId}:bugs:${owner}:`;
|
|
117
|
+
}
|
|
118
|
+
fixedKey(subject) {
|
|
119
|
+
return `krtu:${this.opts.clientId}:bugsFixed:${subject}`;
|
|
120
|
+
}
|
|
121
|
+
/** An event the SDK kept, for the next report's "last events". */
|
|
122
|
+
noteEvent(event) {
|
|
123
|
+
const entry = { type: event.type, at: event.createdAt, ...(event.position ? { position: event.position } : {}) };
|
|
124
|
+
if (event.data && JSON.stringify(event.data).length <= EVENT_CHARS)
|
|
125
|
+
entry.data = event.data;
|
|
126
|
+
this.events.push(entry);
|
|
127
|
+
if (this.events.length > RING_EVENTS)
|
|
128
|
+
this.events.shift();
|
|
129
|
+
const fps = event.type === 'performance' ? event.data?.fps : undefined;
|
|
130
|
+
if (typeof fps === 'number' && Number.isFinite(fps))
|
|
131
|
+
this.lastFps = fps;
|
|
132
|
+
}
|
|
133
|
+
/** A line for the next report's log (the app's own, and the errors the SDK told). */
|
|
134
|
+
log(message) {
|
|
135
|
+
const line = `${new Date().toISOString()} ${String(message).slice(0, LOG_LINE)}`;
|
|
136
|
+
this.logLines.push(line);
|
|
137
|
+
this.logChars += line.length + 1;
|
|
138
|
+
while (this.logChars > LOG_CHARS && this.logLines.length)
|
|
139
|
+
this.logChars -= this.logLines.shift().length + 1;
|
|
140
|
+
}
|
|
141
|
+
async report(input, level) {
|
|
142
|
+
const owner = this.owner();
|
|
143
|
+
if ((await this.store.keys(this.prefix(owner))).length >= MAX_WAITING) {
|
|
144
|
+
throw new KrtuworksError('too_many_reports', `At most ${MAX_WAITING} bug reports wait on this device; they are sent when the app is online.`, 429);
|
|
145
|
+
}
|
|
146
|
+
let screenshot = null;
|
|
147
|
+
if (input.screenshot !== undefined) {
|
|
148
|
+
const raw = input.screenshot instanceof Uint8Array ? input.screenshot : new Uint8Array(input.screenshot instanceof ArrayBuffer ? input.screenshot : await input.screenshot.arrayBuffer());
|
|
149
|
+
const type = screenshotType(raw);
|
|
150
|
+
if (!type)
|
|
151
|
+
throw refused('The screenshot is a PNG, JPEG or WebP picture');
|
|
152
|
+
if (raw.length > MAX_SCREENSHOT)
|
|
153
|
+
throw refused('The screenshot is at most 5 MB');
|
|
154
|
+
screenshot = { bytes: raw.slice(), type };
|
|
155
|
+
}
|
|
156
|
+
const reportId = uuidV4();
|
|
157
|
+
const body = reportBody(input, { reportId, opts: this.opts, level, fps: this.lastFps, events: [...this.events], log: this.logLines.join('\n') });
|
|
158
|
+
const waiting = { reportId, madeAt: Date.now(), body, screenshot, bugId: null };
|
|
159
|
+
await this.store.set(`${this.prefix(owner)}${reportId}`, waiting);
|
|
160
|
+
const subject = this.subject();
|
|
161
|
+
if (subject && !(await this.store.get(this.fixedKey(subject))))
|
|
162
|
+
await this.store.set(this.fixedKey(subject), { told: [] });
|
|
163
|
+
return { reportId };
|
|
164
|
+
}
|
|
165
|
+
/** Reports (with their screenshots) that wait on this device for the player, or for the first player. */
|
|
166
|
+
async waiting() {
|
|
167
|
+
return (await this.store.keys(this.prefix(this.owner()))).length;
|
|
168
|
+
}
|
|
169
|
+
/** Hands one owner's waiting reports to another (the anonymous queue to the first player, a confirmed merge). */
|
|
170
|
+
async move(from, to) {
|
|
171
|
+
if (from === to)
|
|
172
|
+
return;
|
|
173
|
+
for (const key of await this.store.keys(this.prefix(from))) {
|
|
174
|
+
const w = await this.store.get(key);
|
|
175
|
+
if (w)
|
|
176
|
+
await this.store.set(`${this.prefix(to)}${w.reportId}`, w);
|
|
177
|
+
await this.store.delete(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async flush(now = Date.now()) {
|
|
181
|
+
const result = { sent: 0, dropped: [] };
|
|
182
|
+
const subject = this.subject();
|
|
183
|
+
if (!subject)
|
|
184
|
+
return result;
|
|
185
|
+
const keys = await this.store.keys(this.prefix(subject));
|
|
186
|
+
const queue = (await Promise.all(keys.map(async (k) => ({ key: k, w: await this.store.get(k) }))))
|
|
187
|
+
.filter((e) => !!e.w)
|
|
188
|
+
.sort((a, b) => a.w.madeAt - b.w.madeAt);
|
|
189
|
+
for (const { key, w } of queue) {
|
|
190
|
+
if ((this.waitUntil.get(w.reportId) ?? 0) > now)
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
if (!w.bugId) {
|
|
194
|
+
const answer = await this.api.player('/v1/me/bug-reports', { method: 'POST', json: w.body });
|
|
195
|
+
const bugId = answer.json?.report?.id;
|
|
196
|
+
if (typeof bugId !== 'string')
|
|
197
|
+
throw new KrtuworksError('invalid_answer', 'The server answered without the report.');
|
|
198
|
+
w.bugId = bugId;
|
|
199
|
+
if (w.screenshot)
|
|
200
|
+
await this.store.set(key, w);
|
|
201
|
+
}
|
|
202
|
+
if (w.screenshot) {
|
|
203
|
+
try {
|
|
204
|
+
await this.api.player(`/v1/me/bug-reports/${encodeURIComponent(w.bugId)}/screenshot`, {
|
|
205
|
+
method: 'PUT',
|
|
206
|
+
body: w.screenshot.bytes,
|
|
207
|
+
headers: { 'content-type': w.screenshot.type },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
// The report is in: a screenshot it already has is done, one refused for good is let go.
|
|
212
|
+
if (!isKrtuworksError(err) || !SCREENSHOT_REFUSED.has(err.status))
|
|
213
|
+
throw err;
|
|
214
|
+
if (err.code !== 'screenshot_exists')
|
|
215
|
+
result.dropped.push({ reportId: w.reportId, code: err.code, screenshotOnly: true });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await this.store.delete(key);
|
|
219
|
+
this.waitUntil.delete(w.reportId);
|
|
220
|
+
result.sent += 1;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
const e = isKrtuworksError(err) ? err : new KrtuworksError('network_error', String(err));
|
|
224
|
+
if (!w.bugId && (REPORT_REFUSED.has(e.status) || REPORT_REFUSED_CODES.has(e.code))) {
|
|
225
|
+
await this.store.delete(key);
|
|
226
|
+
result.dropped.push({ reportId: w.reportId, code: e.code, screenshotOnly: false });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// Anything else (no answer, a busy or closed server, a player in a merge, a ban, the module
|
|
230
|
+
// off for now) keeps the report for later.
|
|
231
|
+
if (e.code !== 'context_changed')
|
|
232
|
+
this.waitUntil.set(w.reportId, now + (e.retryAfter !== null ? e.retryAfter * 1000 : RETRY_MS));
|
|
233
|
+
return { ...result, stoppedBy: e.code };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
/** The player's reports in this app and environment, newest first; fixes not told yet are told. */
|
|
239
|
+
async mine() {
|
|
240
|
+
const subject = this.subject();
|
|
241
|
+
const answer = await this.api.player('/v1/me/bug-reports');
|
|
242
|
+
const reports = (answer.json?.reports ?? []);
|
|
243
|
+
if (subject)
|
|
244
|
+
await this.tellFixed(subject, reports);
|
|
245
|
+
return reports;
|
|
246
|
+
}
|
|
247
|
+
/** At a player's arrival: only when this device keeps a record for them (they reported here, or the app read their reports). */
|
|
248
|
+
async checkFixed() {
|
|
249
|
+
const subject = this.subject();
|
|
250
|
+
if (subject && (await this.store.get(this.fixedKey(subject))))
|
|
251
|
+
await this.mine();
|
|
252
|
+
}
|
|
253
|
+
async tellFixed(subject, reports) {
|
|
254
|
+
const { fixed, state } = newlyFixed(reports, await this.store.get(this.fixedKey(subject)));
|
|
255
|
+
// A fix counts as told only when the game listens; otherwise it waits for the next look.
|
|
256
|
+
const untold = new Set(this.fixedListeners.size ? [] : fixed.map((r) => r.id));
|
|
257
|
+
await this.store.set(this.fixedKey(subject), { told: state.told.filter((id) => !untold.has(id)) });
|
|
258
|
+
for (const report of fixed) {
|
|
259
|
+
for (const listener of this.fixedListeners) {
|
|
260
|
+
try {
|
|
261
|
+
listener(report);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// the app's listener
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
onFixed(listener) {
|
|
270
|
+
this.fixedListeners.add(listener);
|
|
271
|
+
return () => this.fixedListeners.delete(listener);
|
|
272
|
+
}
|
|
273
|
+
resetBackoff() {
|
|
274
|
+
this.waitUntil.clear();
|
|
275
|
+
}
|
|
276
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { type AvatarReportReason } from './avatarReports.js';
|
|
2
|
+
import { type BugFlushResult, type BugReportInput, type BugReportView } from './bugs.js';
|
|
3
|
+
import { type AppType, type KrtuworksOptions, type Module, type ResolvedOptions } from './config.js';
|
|
4
|
+
import { type EventPosition, type FlushResult } from './events.js';
|
|
5
|
+
import type { Family, FamilyData } from './families.js';
|
|
6
|
+
import { Files } from './files.js';
|
|
7
|
+
import { type IdentityChange, type Player } from './identity.js';
|
|
8
|
+
import { type Entitlement } from './entitlements.js';
|
|
9
|
+
import { type PresenceResult } from './presence.js';
|
|
10
|
+
import { type LevelRef } from './progression.js';
|
|
11
|
+
import { ProgressQueue, type SortOrder, type StatKind, type UploadMethod } from './progress.js';
|
|
12
|
+
import { RemoteConfig, type Switch } from './remoteConfig.js';
|
|
13
|
+
export interface TrackOptions {
|
|
14
|
+
position?: EventPosition;
|
|
15
|
+
/** The version of the app's own schema (or of its extension of a family). */
|
|
16
|
+
schemaVersion?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface Gate {
|
|
19
|
+
maintenance: {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
message: string | null;
|
|
22
|
+
} | null;
|
|
23
|
+
/** The owner's minimum version is newer than this build: the player must update from the store. */
|
|
24
|
+
updateRequired: boolean;
|
|
25
|
+
switchedOff: Switch | null;
|
|
26
|
+
}
|
|
27
|
+
export interface ProgressView {
|
|
28
|
+
locale: string | null;
|
|
29
|
+
stats: Array<{
|
|
30
|
+
key: string;
|
|
31
|
+
name: string;
|
|
32
|
+
description: string | null;
|
|
33
|
+
kind: StatKind;
|
|
34
|
+
valueType: string;
|
|
35
|
+
value: number | null;
|
|
36
|
+
updatedAt: string | null;
|
|
37
|
+
/** The stat on each platform (a Steam stat's API name); the native SDKs mirror it there. */
|
|
38
|
+
platformIds: Record<string, string>;
|
|
39
|
+
}>;
|
|
40
|
+
achievements: Array<{
|
|
41
|
+
key: string;
|
|
42
|
+
id: string;
|
|
43
|
+
name: string | null;
|
|
44
|
+
description: string | null;
|
|
45
|
+
hidden: boolean;
|
|
46
|
+
retired: boolean;
|
|
47
|
+
unlocked: boolean;
|
|
48
|
+
unlockedAt: string | null;
|
|
49
|
+
platformIds: Record<string, string>;
|
|
50
|
+
iconUrl: string | null;
|
|
51
|
+
lockedIconUrl: string | null;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
export type LeaderboardDisplay = 'NUMERIC' | 'TIME_SECONDS' | 'TIME_MILLISECONDS';
|
|
55
|
+
/** One of the app's leaderboards with the player's own score and rank (`rank`, `score`, `details` are null while the player is not on it). */
|
|
56
|
+
export interface LeaderboardView {
|
|
57
|
+
key: string;
|
|
58
|
+
name: string;
|
|
59
|
+
description: string | null;
|
|
60
|
+
sortOrder: SortOrder;
|
|
61
|
+
display: LeaderboardDisplay;
|
|
62
|
+
/** Present when the server tells it: the SDK folds waiting scores only for a board whose method it knows. */
|
|
63
|
+
uploadMethod?: UploadMethod;
|
|
64
|
+
importFromPlatform: boolean;
|
|
65
|
+
/** The board on each platform (Steam's leaderboard name). */
|
|
66
|
+
platformIds: Record<string, string>;
|
|
67
|
+
score: number | null;
|
|
68
|
+
/** Base64 of the bytes submitted with the score. */
|
|
69
|
+
details: string | null;
|
|
70
|
+
/** Competition rank: equal scores share one. */
|
|
71
|
+
rank: number | null;
|
|
72
|
+
/** Rows on the board. */
|
|
73
|
+
entries: number;
|
|
74
|
+
}
|
|
75
|
+
export interface LeaderboardsView {
|
|
76
|
+
locale: string | null;
|
|
77
|
+
leaderboards: LeaderboardView[];
|
|
78
|
+
}
|
|
79
|
+
export interface LeaderboardEntry {
|
|
80
|
+
rank: number;
|
|
81
|
+
/** The id the app knows the player by (pairwise). */
|
|
82
|
+
subject: string | null;
|
|
83
|
+
/** The player's Krtuworks username; null when there is none or it could not be read (show "Player"). */
|
|
84
|
+
name: string | null;
|
|
85
|
+
score: number;
|
|
86
|
+
/** Base64 of the bytes submitted with the score. */
|
|
87
|
+
details: string | null;
|
|
88
|
+
updatedAt: string;
|
|
89
|
+
/** The row is the signed-in player's. */
|
|
90
|
+
you: boolean;
|
|
91
|
+
}
|
|
92
|
+
export interface LeaderboardEntriesView {
|
|
93
|
+
key: string;
|
|
94
|
+
scope: 'global' | 'around';
|
|
95
|
+
sortOrder: SortOrder;
|
|
96
|
+
display: LeaderboardDisplay;
|
|
97
|
+
total: number;
|
|
98
|
+
entries: LeaderboardEntry[];
|
|
99
|
+
}
|
|
100
|
+
export interface LeaderboardEntriesOptions {
|
|
101
|
+
/** `global` reads from `offset`; `around` centres the window on the player (default `global`). */
|
|
102
|
+
scope?: 'global' | 'around';
|
|
103
|
+
/** 1-100 (server default 25). */
|
|
104
|
+
limit?: number;
|
|
105
|
+
/** Global scope only. */
|
|
106
|
+
offset?: number;
|
|
107
|
+
}
|
|
108
|
+
/** Why the SDK suggests a guest links a Krtuworks ID now. */
|
|
109
|
+
export type LinkReminderReason = 'first_achievement' | 'first_save' | 'play_time';
|
|
110
|
+
export interface MergeSettled {
|
|
111
|
+
previousSubject: string;
|
|
112
|
+
/** Saves the merged account left waiting on this device: the player chooses (files.adoptWaiting / discardWaiting). */
|
|
113
|
+
waitingSaves: Array<{
|
|
114
|
+
uploadId: string;
|
|
115
|
+
slot: string;
|
|
116
|
+
savedAt: number;
|
|
117
|
+
}>;
|
|
118
|
+
}
|
|
119
|
+
export interface SendResult {
|
|
120
|
+
progress: Awaited<ReturnType<ProgressQueue['flush']>>;
|
|
121
|
+
bugs: BugFlushResult;
|
|
122
|
+
events: FlushResult;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The Krtuworks SDK for web games and apps. One instance per app and environment:
|
|
126
|
+
*
|
|
127
|
+
* const krtu = await Krtuworks.initialize({ config, environment: 'LIVE', platform: 'WEB', build: '1.0.0' });
|
|
128
|
+
* await krtu.signInAsGuest({ birthDate: '2001-04-02', acceptedTerms: CURRENT_TERMS });
|
|
129
|
+
* krtu.track('screen_view', { screen: 'menu' });
|
|
130
|
+
* await krtu.incrementStat('coins', 5);
|
|
131
|
+
*/
|
|
132
|
+
export declare class Krtuworks {
|
|
133
|
+
readonly options: ResolvedOptions;
|
|
134
|
+
readonly config: RemoteConfig;
|
|
135
|
+
readonly files: Files;
|
|
136
|
+
private readonly store;
|
|
137
|
+
private readonly clock;
|
|
138
|
+
private readonly identity;
|
|
139
|
+
private readonly api;
|
|
140
|
+
private readonly events;
|
|
141
|
+
private readonly progress;
|
|
142
|
+
private readonly sessions;
|
|
143
|
+
private readonly levels;
|
|
144
|
+
private readonly bugs;
|
|
145
|
+
private readonly presence;
|
|
146
|
+
private readonly licences;
|
|
147
|
+
private readonly fetchFn;
|
|
148
|
+
private optedOut;
|
|
149
|
+
private experiments;
|
|
150
|
+
private flushTimer;
|
|
151
|
+
private configTimer;
|
|
152
|
+
private readonly mergeListeners;
|
|
153
|
+
private readonly reminderListeners;
|
|
154
|
+
private countedActive;
|
|
155
|
+
private sessionOwner;
|
|
156
|
+
private readonly detach;
|
|
157
|
+
private settling;
|
|
158
|
+
private constructor();
|
|
159
|
+
/** Makes the SDK ready: the player and the remote config this device kept, then the server in the background. */
|
|
160
|
+
static initialize(options: KrtuworksOptions): Promise<Krtuworks>;
|
|
161
|
+
get player(): Player | null;
|
|
162
|
+
/** GAME, MOBILE_APP, WEB_APP or DESKTOP_APP, from krtuworks-config.json. */
|
|
163
|
+
get appType(): AppType;
|
|
164
|
+
onIdentityChange(listener: (change: IdentityChange) => void): () => void;
|
|
165
|
+
/** Called when a merged account's waiting queues moved to the signed-in player (after the server confirmed). */
|
|
166
|
+
onMergeSettled(listener: (merge: MergeSettled) => void): () => void;
|
|
167
|
+
/** Plays as a guest on this device; a new guest needs the age screen's answers. */
|
|
168
|
+
signInAsGuest(input?: {
|
|
169
|
+
birthDate?: string;
|
|
170
|
+
acceptedTerms?: string;
|
|
171
|
+
}): Promise<Player>;
|
|
172
|
+
/** The Krtuworks ID sign-in address (a guest playing here is carried along unless `carryGuest: false`). */
|
|
173
|
+
signInUrl(options?: {
|
|
174
|
+
redirectUri?: string;
|
|
175
|
+
carryGuest?: boolean;
|
|
176
|
+
}): Promise<string>;
|
|
177
|
+
/** In a browser: goes to the sign-in page. */
|
|
178
|
+
signIn(options?: {
|
|
179
|
+
redirectUri?: string;
|
|
180
|
+
carryGuest?: boolean;
|
|
181
|
+
}): Promise<void>;
|
|
182
|
+
/** Finishes the sign-in on the page the player came back to (`location.href` by default). */
|
|
183
|
+
completeSignIn(callbackUrl?: string | URL): Promise<Player>;
|
|
184
|
+
signOut(): Promise<void>;
|
|
185
|
+
private identityChanged;
|
|
186
|
+
/**
|
|
187
|
+
* What follows a player's arrival: the anonymous queues go to the first player; a merged account's
|
|
188
|
+
* waiting queues move here once the server confirms the merge; the
|
|
189
|
+
* session opens; unfinished saves are finished; the queues are sent. Without a player, a guest the
|
|
190
|
+
* server could not make on an earlier launch is tried again.
|
|
191
|
+
*/
|
|
192
|
+
private settle;
|
|
193
|
+
private settleMerges;
|
|
194
|
+
/**
|
|
195
|
+
* Records an event (a family or the app's own type). Nothing is made when its module is off, the
|
|
196
|
+
* owner switched this build's telemetry off, or the player turned statistics off. Returns whether
|
|
197
|
+
* the event was kept.
|
|
198
|
+
*/
|
|
199
|
+
track<F extends Family>(type: F, data: FamilyData[F], options?: TrackOptions): boolean;
|
|
200
|
+
track(type: string, data?: Record<string, unknown>, options?: TrackOptions): boolean;
|
|
201
|
+
private telemetryAllowed;
|
|
202
|
+
/** Levels: the SDK adds the attempt, its times and deaths. Levels and deaths are for games only (nothing happens in an app). */
|
|
203
|
+
readonly level: {
|
|
204
|
+
start: (level: LevelRef) => Promise<void>;
|
|
205
|
+
complete: (score?: number) => void;
|
|
206
|
+
fail: (score?: number) => void;
|
|
207
|
+
quit: () => void;
|
|
208
|
+
checkpoint: (name: string) => void;
|
|
209
|
+
restart: () => Promise<void>;
|
|
210
|
+
};
|
|
211
|
+
/** A death, counted toward the level in play and told with its place. Games only: false in an app. */
|
|
212
|
+
death(cause: string, extra?: {
|
|
213
|
+
enemy?: string;
|
|
214
|
+
position?: EventPosition;
|
|
215
|
+
}): boolean;
|
|
216
|
+
/** An error the game caught, as the errors family tells it. */
|
|
217
|
+
error(err: unknown, extra?: {
|
|
218
|
+
kind?: FamilyData['error']['kind'];
|
|
219
|
+
fingerprint?: string;
|
|
220
|
+
handled?: boolean;
|
|
221
|
+
}): boolean;
|
|
222
|
+
/**
|
|
223
|
+
* A bug report from the player (the categories are a fixed menu). The SDK adds the platform, build,
|
|
224
|
+
* device, language, level in play, frame rate, the last 50 events and the log lines. The report is
|
|
225
|
+
* kept on this device first and sent when it can be, its screenshot right after it; a player sends at
|
|
226
|
+
* most 5 an hour and 20 a day in an app (more wait with `too_many_reports`). Refused at once when the
|
|
227
|
+
* owner turned bug reports off (`module_disabled`) or the input can never be taken (`invalid_request`).
|
|
228
|
+
*/
|
|
229
|
+
reportBug(input: BugReportInput): Promise<{
|
|
230
|
+
reportId: string;
|
|
231
|
+
}>;
|
|
232
|
+
/** A line for the bug reports' log (kept in memory, the last 32 KB; sent only inside a report the player makes). */
|
|
233
|
+
log(message: string): void;
|
|
234
|
+
/** The player's bug reports in this app, newest first, with their status and the build a fix shipped in. */
|
|
235
|
+
myBugReports(): Promise<BugReportView[]>;
|
|
236
|
+
/**
|
|
237
|
+
* Called for each of the player's reports that was fixed since the app last looked (at a player's
|
|
238
|
+
* arrival, and on myBugReports): the game may tell the player "fixed in <fixedIn>".
|
|
239
|
+
*/
|
|
240
|
+
onBugFixed(listener: (report: BugReportView) => void): () => void;
|
|
241
|
+
/**
|
|
242
|
+
* Reports another player's profile picture: `subject` is the id your app knows them by (another
|
|
243
|
+
* player of this app, e.g. from your game server), the reason one of AVATAR_REPORT_REASONS, an
|
|
244
|
+
* optional note of at most 300 characters. Krtuworks reviews it; the reporter is never told an
|
|
245
|
+
* outcome. Once per player and picture (again answers `already: true`), at most 10 a day
|
|
246
|
+
* (`too_many_reports`). Needs a connection: nothing waits on this device.
|
|
247
|
+
*/
|
|
248
|
+
reportAvatar(subject: string, reason: AvatarReportReason, note?: string): Promise<{
|
|
249
|
+
already: boolean;
|
|
250
|
+
}>;
|
|
251
|
+
/** The player's statistics choice in this app (on by default). Off: no event is made or kept. */
|
|
252
|
+
getTelemetryOptOut(): Promise<boolean>;
|
|
253
|
+
setTelemetryOptOut(optedOut: boolean): Promise<void>;
|
|
254
|
+
private optOutKey;
|
|
255
|
+
private readOptOut;
|
|
256
|
+
/** Adds to a SUM stat. Kept on the device until the server has it; never lost, never counted twice. */
|
|
257
|
+
incrementStat(key: string, by?: number): Promise<void>;
|
|
258
|
+
/** Reports a value to a MAX, MIN or LATEST stat. */
|
|
259
|
+
setStat(key: string, value: number): Promise<void>;
|
|
260
|
+
/**
|
|
261
|
+
* Submits the player's score to a leaderboard (a whole number below 10^15; `details` 1-64 bytes the game
|
|
262
|
+
* keeps with it). Kept on the device until the server has it, like a stat; the server decides what the
|
|
263
|
+
* board keeps (KEEP_BEST or FORCE_UPDATE) and mirrors to the platform itself.
|
|
264
|
+
*/
|
|
265
|
+
submitScore(key: string, value: number, options?: {
|
|
266
|
+
details?: Uint8Array;
|
|
267
|
+
}): Promise<void>;
|
|
268
|
+
unlockAchievement(key: string): Promise<void>;
|
|
269
|
+
/** The app's stats and achievements with the player's values (names in the SDK's locale where the app has them). */
|
|
270
|
+
getProgress(): Promise<ProgressView>;
|
|
271
|
+
/** The app's leaderboards with the player's own score and rank (names in the SDK's locale where the app has them). */
|
|
272
|
+
getLeaderboards(options?: {
|
|
273
|
+
locale?: string;
|
|
274
|
+
}): Promise<LeaderboardsView>;
|
|
275
|
+
/** A leaderboard's rows in its order: from `offset`, or the window around the player. */
|
|
276
|
+
getLeaderboardEntries(key: string, options?: LeaderboardEntriesOptions): Promise<LeaderboardEntriesView>;
|
|
277
|
+
/**
|
|
278
|
+
* Lines achievements up with a platform's: unlocks here what the platform has, answers what to unlock
|
|
279
|
+
* there (`toPlatform`) and what support took back here that the platform still has (`toClear`). The open
|
|
280
|
+
* session goes with it: the server mirrors only with the platform account the session proved (Steam:
|
|
281
|
+
* required, `409 platform_link_mismatch {state}` when that account is not mirrored for this player).
|
|
282
|
+
*/
|
|
283
|
+
syncPlatformAchievements(unlocked: string[]): Promise<{
|
|
284
|
+
unlockedHere: string[];
|
|
285
|
+
toPlatform: string[];
|
|
286
|
+
toClear: string[];
|
|
287
|
+
}>;
|
|
288
|
+
/** Progress the server refused for good (a retired stat, a wrong value): for the game's own diagnostics. */
|
|
289
|
+
get progressRefusals(): import("./progress.js").ProgressRefusal[];
|
|
290
|
+
/** Only a Steam game has licences the server reads from the platform today; a launch asks for them once the player is here. */
|
|
291
|
+
private refreshLicences;
|
|
292
|
+
/**
|
|
293
|
+
* The player's licences as the server last said them (kept on this device, so the last answer of an
|
|
294
|
+
* earlier launch is here when the server cannot be reached): the game (`sku` base) first, then the
|
|
295
|
+
* DLCs. `checked` marks what the server confirmed with the platform in this launch. Empty for an app
|
|
296
|
+
* that is not a game.
|
|
297
|
+
*/
|
|
298
|
+
entitlements(): Entitlement[];
|
|
299
|
+
/** Asks the server for the stored licences now (`GET /v1/me/entitlements`) and keeps the answer. Throws when the server cannot be reached. */
|
|
300
|
+
refreshEntitlements(): Promise<Entitlement[]>;
|
|
301
|
+
/**
|
|
302
|
+
* Whether the player owns the DLC with this product key: what the server confirmed with the platform
|
|
303
|
+
* in this launch; else the last answer the server gave (a browser has no platform client of its own
|
|
304
|
+
* to ask). False for an unknown key and for an app that is not a game.
|
|
305
|
+
*/
|
|
306
|
+
ownsDlc(key: string): boolean;
|
|
307
|
+
private presenceOn;
|
|
308
|
+
private refreshPresence;
|
|
309
|
+
/**
|
|
310
|
+
* Shows what the player is doing (a state the owner declared on the console, with its parameters).
|
|
311
|
+
* Never throws for the reasons in the result. A browser has no platform presence to write, so a call
|
|
312
|
+
* that passes every rule answers `no_platform`. `LEVEL` and `SESSION_MINUTES` fill themselves when
|
|
313
|
+
* the state declares them and the call does not give them.
|
|
314
|
+
*/
|
|
315
|
+
setPresence(key: string, params?: Record<string, string | number>): Promise<PresenceResult>;
|
|
316
|
+
/** Takes the shown presence away. A browser shows none, so there is nothing to clear. */
|
|
317
|
+
clearPresence(): void;
|
|
318
|
+
/** Tells the platform the player's party (`null` leaves it). A browser has no platform presence, so this does nothing. */
|
|
319
|
+
setPresenceGroup(_groupId: string | null, _size?: number): void;
|
|
320
|
+
/** Whether the player shows their presence; null until the server has said (or when the module is off). */
|
|
321
|
+
presenceVisible(): boolean | null;
|
|
322
|
+
/** The player's choice to show their presence. Throws `module_disabled` when the owner turned presence off. */
|
|
323
|
+
setPresenceVisible(visible: boolean): Promise<void>;
|
|
324
|
+
refreshConfig(): Promise<void>;
|
|
325
|
+
/** The owner's gates for this build: maintenance, a newer minimum version, a switch. */
|
|
326
|
+
gate(): Gate;
|
|
327
|
+
/** The modules the owner left on (all of them until the config says otherwise; in an app the game-only ones are off). */
|
|
328
|
+
modules(): Record<Module, boolean>;
|
|
329
|
+
/** The player's A/B groups; their values take over the config's (`value`). */
|
|
330
|
+
refreshExperiments(): Promise<Array<{
|
|
331
|
+
key: string;
|
|
332
|
+
configKey: string;
|
|
333
|
+
group: string;
|
|
334
|
+
value: unknown;
|
|
335
|
+
}>>;
|
|
336
|
+
/** A remote config value, the player's A/B group's when one varies it. */
|
|
337
|
+
value<T = unknown>(key: string, fallback?: T): T | undefined;
|
|
338
|
+
getStatus(): Promise<Record<string, unknown>>;
|
|
339
|
+
getProfile(): Promise<{
|
|
340
|
+
fields: Record<string, string | number | boolean>;
|
|
341
|
+
updatedAt: string | null;
|
|
342
|
+
}>;
|
|
343
|
+
/** Writes the named fields of the player's card (null clears one). */
|
|
344
|
+
updateProfile(fields: Record<string, string | number | boolean | null>): Promise<{
|
|
345
|
+
fields: Record<string, string | number | boolean>;
|
|
346
|
+
updatedAt: string | null;
|
|
347
|
+
}>;
|
|
348
|
+
private guestCall;
|
|
349
|
+
/** Asks to delete this guest account (90 days; signing in as the guest is then only for cancelling or the data copy). */
|
|
350
|
+
requestGuestDeletion(): Promise<{
|
|
351
|
+
deletionDate: string;
|
|
352
|
+
}>;
|
|
353
|
+
cancelGuestDeletion(): Promise<{
|
|
354
|
+
status: string;
|
|
355
|
+
}>;
|
|
356
|
+
/** The guest's data copy: asks for a new one with `request`; a ready copy comes with a 15-minute download address. */
|
|
357
|
+
guestDataExport(options?: {
|
|
358
|
+
request?: boolean;
|
|
359
|
+
}): Promise<Record<string, unknown>>;
|
|
360
|
+
/**
|
|
361
|
+
* Called at the moments a guest has something to lose (the first achievement, the first save, a while
|
|
362
|
+
* of play) when the app's `linkReminder` setting says it is time: the game shows its own prompt and,
|
|
363
|
+
* if the player agrees, calls `signIn()`. Remote config `linkReminder`: `{ enabled, afterMinutes, everyDays }`
|
|
364
|
+
* (default on, 30 minutes, every 3 days).
|
|
365
|
+
*/
|
|
366
|
+
onLinkReminder(listener: (reason: LinkReminderReason) => void): () => void;
|
|
367
|
+
private remindLinking;
|
|
368
|
+
private flushSoon;
|
|
369
|
+
private scheduleFlush;
|
|
370
|
+
private flushing;
|
|
371
|
+
/**
|
|
372
|
+
* Sends what waits: progress first (it must not wait behind telemetry), then bug reports, then events.
|
|
373
|
+
* One send at a time: a call made while one runs gets that one's result.
|
|
374
|
+
*/
|
|
375
|
+
flush(): Promise<SendResult>;
|
|
376
|
+
private send;
|
|
377
|
+
/** @internal The validator's round: a session opened, kept alive once and ended, every step answered. */
|
|
378
|
+
sessionRound(): Promise<void>;
|
|
379
|
+
/** What waits on this device for the signed-in player (or the anonymous queue). */
|
|
380
|
+
waiting(): Promise<{
|
|
381
|
+
events: number;
|
|
382
|
+
progress: number;
|
|
383
|
+
bugReports: number;
|
|
384
|
+
}>;
|
|
385
|
+
private watchPage;
|
|
386
|
+
/** Waits for the work that follows a player's arrival (tests, or a game that wants to know it is done). */
|
|
387
|
+
ready(): Promise<void>;
|
|
388
|
+
/** Stops the SDK: the level in play ends as a quit, the session ends, what waits is written to the store. */
|
|
389
|
+
shutdown(): Promise<void>;
|
|
390
|
+
}
|