@krtuworks/sdk 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +29 -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/client.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { Api } from './api.js';
|
|
2
|
+
import { avatarReportBody } from './avatarReports.js';
|
|
3
|
+
import { BugReports } from './bugs.js';
|
|
4
|
+
import { Clock } from './clock.js';
|
|
5
|
+
import { moduleOfEventType, resolveOptions } from './config.js';
|
|
6
|
+
import { KrtuworksError } from './errors.js';
|
|
7
|
+
import { ANON, EventQueue } from './events.js';
|
|
8
|
+
import { Files } from './files.js';
|
|
9
|
+
import { Identity } from './identity.js';
|
|
10
|
+
import { Entitlements } from './entitlements.js';
|
|
11
|
+
import { Presence } from './presence.js';
|
|
12
|
+
import { ProgressionTracker } from './progression.js';
|
|
13
|
+
import { ProgressQueue } from './progress.js';
|
|
14
|
+
import { RemoteConfig, olderThan } from './remoteConfig.js';
|
|
15
|
+
import { SessionKeeper } from './sessions.js';
|
|
16
|
+
import { defaultStore } from './store.js';
|
|
17
|
+
import { errorOf, form, send } from './transport.js';
|
|
18
|
+
const FLUSH_MS = 30_000;
|
|
19
|
+
const CONFIG_MS = 5 * 60_000;
|
|
20
|
+
const PW = /^pw_[A-Za-z0-9_-]{8,120}$/;
|
|
21
|
+
const EVENT_TYPE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
22
|
+
/**
|
|
23
|
+
* The Krtuworks SDK for web games and apps. One instance per app and environment:
|
|
24
|
+
*
|
|
25
|
+
* const krtu = await Krtuworks.initialize({ config, environment: 'LIVE', platform: 'WEB', build: '1.0.0' });
|
|
26
|
+
* await krtu.signInAsGuest({ birthDate: '2001-04-02', acceptedTerms: CURRENT_TERMS });
|
|
27
|
+
* krtu.track('screen_view', { screen: 'menu' });
|
|
28
|
+
* await krtu.incrementStat('coins', 5);
|
|
29
|
+
*/
|
|
30
|
+
export class Krtuworks {
|
|
31
|
+
options;
|
|
32
|
+
config;
|
|
33
|
+
files;
|
|
34
|
+
store;
|
|
35
|
+
clock = new Clock();
|
|
36
|
+
identity;
|
|
37
|
+
api;
|
|
38
|
+
events;
|
|
39
|
+
progress;
|
|
40
|
+
sessions;
|
|
41
|
+
levels;
|
|
42
|
+
bugs;
|
|
43
|
+
presence;
|
|
44
|
+
licences;
|
|
45
|
+
fetchFn;
|
|
46
|
+
optedOut = false;
|
|
47
|
+
experiments = new Map();
|
|
48
|
+
flushTimer = null;
|
|
49
|
+
configTimer = null;
|
|
50
|
+
mergeListeners = new Set();
|
|
51
|
+
reminderListeners = new Set();
|
|
52
|
+
countedActive = 0;
|
|
53
|
+
sessionOwner = null;
|
|
54
|
+
detach = [];
|
|
55
|
+
settling = Promise.resolve();
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.options = resolveOptions(options);
|
|
58
|
+
this.store = options.store ?? defaultStore();
|
|
59
|
+
this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
60
|
+
this.identity = new Identity({ opts: this.options, store: this.store, fetch: this.fetchFn, clock: this.clock });
|
|
61
|
+
this.api = new Api(this.options, this.identity, this.fetchFn, this.clock);
|
|
62
|
+
const subject = () => this.identity.player?.subject ?? null;
|
|
63
|
+
this.events = new EventQueue({ opts: this.options, store: this.store, clock: this.clock, api: this.api, subject });
|
|
64
|
+
this.progress = new ProgressQueue({ opts: this.options, store: this.store, api: this.api, subject });
|
|
65
|
+
this.licences = new Entitlements(this.options.clientId, this.store, this.api, subject);
|
|
66
|
+
this.sessions = new SessionKeeper(this.options, this.api, subject, (items) => this.licences.fromSession(items));
|
|
67
|
+
this.config = new RemoteConfig(this.options, this.api, this.store);
|
|
68
|
+
this.bugs = new BugReports(this.options, this.store, this.api, () => subject() ?? ANON, subject);
|
|
69
|
+
this.presence = new Presence(this.options.clientId, this.store, this.api, subject);
|
|
70
|
+
this.files = new Files(this.options, this.api, this.store, subject, () => this.identity.generation, () => void this.remindLinking('first_save').catch(() => undefined));
|
|
71
|
+
this.levels = new ProgressionTracker(this.store, this.options.clientId, () => subject() ?? ANON, () => this.sessions.activeSeconds(), (data) => void this.track('progression', data));
|
|
72
|
+
}
|
|
73
|
+
/** Makes the SDK ready: the player and the remote config this device kept, then the server in the background. */
|
|
74
|
+
static async initialize(options) {
|
|
75
|
+
const krtu = new Krtuworks(options);
|
|
76
|
+
krtu.sessionOwner = (await krtu.identity.load())?.subject ?? null;
|
|
77
|
+
await krtu.config.load();
|
|
78
|
+
await krtu.presence.load();
|
|
79
|
+
await krtu.licences.load();
|
|
80
|
+
krtu.detach.push(krtu.identity.onChange((change) => krtu.identityChanged(change)));
|
|
81
|
+
krtu.watchPage();
|
|
82
|
+
krtu.flushTimer = setInterval(() => void krtu.flush().catch(() => undefined), FLUSH_MS);
|
|
83
|
+
krtu.configTimer = setInterval(() => void krtu.refreshConfig().catch(() => undefined), CONFIG_MS);
|
|
84
|
+
for (const t of [krtu.flushTimer, krtu.configTimer])
|
|
85
|
+
t.unref?.();
|
|
86
|
+
void krtu.refreshConfig().catch(() => undefined);
|
|
87
|
+
krtu.settling = krtu.settle();
|
|
88
|
+
return krtu;
|
|
89
|
+
}
|
|
90
|
+
// ── Who plays ─────────────────────────────────────────────────────────────
|
|
91
|
+
get player() {
|
|
92
|
+
return this.identity.player;
|
|
93
|
+
}
|
|
94
|
+
/** GAME, MOBILE_APP, WEB_APP or DESKTOP_APP, from krtuworks-config.json. */
|
|
95
|
+
get appType() {
|
|
96
|
+
return this.options.type;
|
|
97
|
+
}
|
|
98
|
+
onIdentityChange(listener) {
|
|
99
|
+
return this.identity.onChange(listener);
|
|
100
|
+
}
|
|
101
|
+
/** Called when a merged account's waiting queues moved to the signed-in player (after the server confirmed). */
|
|
102
|
+
onMergeSettled(listener) {
|
|
103
|
+
this.mergeListeners.add(listener);
|
|
104
|
+
return () => this.mergeListeners.delete(listener);
|
|
105
|
+
}
|
|
106
|
+
/** Plays as a guest on this device; a new guest needs the age screen's answers. */
|
|
107
|
+
async signInAsGuest(input = {}) {
|
|
108
|
+
return this.identity.signInAsGuest(input);
|
|
109
|
+
}
|
|
110
|
+
/** The Krtuworks ID sign-in address (a guest playing here is carried along unless `carryGuest: false`). */
|
|
111
|
+
async signInUrl(options = {}) {
|
|
112
|
+
return this.identity.beginSignIn(options);
|
|
113
|
+
}
|
|
114
|
+
/** In a browser: goes to the sign-in page. */
|
|
115
|
+
async signIn(options = {}) {
|
|
116
|
+
const url = await this.signInUrl(options);
|
|
117
|
+
await this.events.persist();
|
|
118
|
+
window.location.assign(url);
|
|
119
|
+
}
|
|
120
|
+
/** Finishes the sign-in on the page the player came back to (`location.href` by default). */
|
|
121
|
+
async completeSignIn(callbackUrl) {
|
|
122
|
+
const url = callbackUrl ?? (typeof window === 'undefined' ? undefined : window.location.href);
|
|
123
|
+
if (!url)
|
|
124
|
+
throw new TypeError('completeSignIn needs the address the player came back to');
|
|
125
|
+
return this.identity.completeSignIn(url);
|
|
126
|
+
}
|
|
127
|
+
async signOut() {
|
|
128
|
+
await this.flush().catch(() => undefined);
|
|
129
|
+
await this.sessions.end();
|
|
130
|
+
await this.identity.signOut();
|
|
131
|
+
}
|
|
132
|
+
identityChanged(change) {
|
|
133
|
+
if (change.type === 'merged' || change.type === 'signed_out' || change.type === 'deletion_pending' || change.type === 'sanctioned') {
|
|
134
|
+
this.sessions.close();
|
|
135
|
+
this.presence.forget();
|
|
136
|
+
this.licences.forget();
|
|
137
|
+
this.sessionOwner = null;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// A guest that became a Krtuworks ID is the same player: its session goes on.
|
|
141
|
+
if (this.sessionOwner !== change.player.subject) {
|
|
142
|
+
this.sessions.close();
|
|
143
|
+
this.presence.forget();
|
|
144
|
+
this.licences.forget();
|
|
145
|
+
}
|
|
146
|
+
this.sessionOwner = change.player.subject;
|
|
147
|
+
this.events.resetBackoff();
|
|
148
|
+
this.progress.resetBackoff();
|
|
149
|
+
this.bugs.resetBackoff();
|
|
150
|
+
this.settling = this.settling.then(() => this.settle()).catch(() => undefined);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* What follows a player's arrival: the anonymous queues go to the first player; a merged account's
|
|
154
|
+
* waiting queues move here once the server confirms the merge; the
|
|
155
|
+
* session opens; unfinished saves are finished; the queues are sent. Without a player, a guest the
|
|
156
|
+
* server could not make on an earlier launch is tried again.
|
|
157
|
+
*/
|
|
158
|
+
async settle() {
|
|
159
|
+
const player = this.identity.player;
|
|
160
|
+
if (!player) {
|
|
161
|
+
if ((await this.identity.meta()).guestRequest)
|
|
162
|
+
await this.identity.signInAsGuest().catch(() => undefined);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await this.events.move(ANON, player.subject);
|
|
166
|
+
await this.progress.move(ANON, player.subject);
|
|
167
|
+
await this.bugs.move(ANON, player.subject);
|
|
168
|
+
await this.settleMerges(player.subject).catch(() => undefined);
|
|
169
|
+
await this.readOptOut().catch(() => undefined);
|
|
170
|
+
await this.licences.ensureLoaded();
|
|
171
|
+
if (this.options.sessions && this.config.moduleOn('sessions'))
|
|
172
|
+
await this.sessions.open().catch(() => undefined);
|
|
173
|
+
await this.refreshLicences();
|
|
174
|
+
await this.refreshPresence();
|
|
175
|
+
await this.files.resume().catch(() => undefined);
|
|
176
|
+
await this.flush().catch(() => undefined);
|
|
177
|
+
if (this.config.moduleOn('bugReports'))
|
|
178
|
+
await this.bugs.checkFixed().catch(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
async settleMerges(subject) {
|
|
181
|
+
for (const previous of await this.identity.waitingOwners()) {
|
|
182
|
+
if (previous === subject || !PW.test(previous)) {
|
|
183
|
+
await this.identity.forgetWaitingOwner(previous);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const answer = await this.api.player('/v1/me/merge-check', { method: 'POST', json: { previousSubject: previous } });
|
|
187
|
+
if (answer.json?.confirmed !== true)
|
|
188
|
+
continue;
|
|
189
|
+
await this.events.move(previous, subject);
|
|
190
|
+
await this.progress.move(previous, subject);
|
|
191
|
+
await this.bugs.move(previous, subject);
|
|
192
|
+
await this.identity.forgetWaitingOwner(previous);
|
|
193
|
+
const settled = { previousSubject: previous, waitingSaves: await this.files.waitingFor(previous) };
|
|
194
|
+
for (const listener of this.mergeListeners) {
|
|
195
|
+
try {
|
|
196
|
+
listener(settled);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// the app's listener
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
track(type, data, options = {}) {
|
|
205
|
+
if (!EVENT_TYPE.test(type))
|
|
206
|
+
throw new TypeError(`"${type}" is not an event type (a lowercase letter, then lowercase letters, digits and _; at most 64)`);
|
|
207
|
+
if (!this.telemetryAllowed(moduleOfEventType(type)))
|
|
208
|
+
return false;
|
|
209
|
+
const sessionId = this.sessions.session?.id;
|
|
210
|
+
const made = this.events.add({
|
|
211
|
+
type,
|
|
212
|
+
...(sessionId ? { sessionId } : {}),
|
|
213
|
+
...(options.schemaVersion !== undefined ? { schemaVersion: options.schemaVersion } : {}),
|
|
214
|
+
...(options.position ? { position: options.position } : {}),
|
|
215
|
+
...(data && Object.keys(data).length ? { data } : {}),
|
|
216
|
+
});
|
|
217
|
+
this.bugs.noteEvent(made);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
telemetryAllowed(module) {
|
|
221
|
+
if (this.optedOut)
|
|
222
|
+
return false;
|
|
223
|
+
if (this.config.switchForBuild())
|
|
224
|
+
return false;
|
|
225
|
+
return this.config.moduleOn(module);
|
|
226
|
+
}
|
|
227
|
+
/** Levels: the SDK adds the attempt, its times and deaths. Levels and deaths are for games only (nothing happens in an app). */
|
|
228
|
+
level = {
|
|
229
|
+
start: async (level) => {
|
|
230
|
+
if (this.options.game)
|
|
231
|
+
await this.levels.start(level);
|
|
232
|
+
},
|
|
233
|
+
complete: (score) => {
|
|
234
|
+
if (this.options.game)
|
|
235
|
+
this.levels.finish('complete', { score });
|
|
236
|
+
},
|
|
237
|
+
fail: (score) => {
|
|
238
|
+
if (this.options.game)
|
|
239
|
+
this.levels.finish('fail', { score });
|
|
240
|
+
},
|
|
241
|
+
quit: () => {
|
|
242
|
+
if (this.options.game)
|
|
243
|
+
this.levels.finish('quit');
|
|
244
|
+
},
|
|
245
|
+
checkpoint: (name) => {
|
|
246
|
+
if (this.options.game)
|
|
247
|
+
this.levels.checkpoint(name);
|
|
248
|
+
},
|
|
249
|
+
restart: async () => {
|
|
250
|
+
if (this.options.game)
|
|
251
|
+
await this.levels.restart();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
/** A death, counted toward the level in play and told with its place. Games only: false in an app. */
|
|
255
|
+
death(cause, extra = {}) {
|
|
256
|
+
if (!this.options.game)
|
|
257
|
+
return false;
|
|
258
|
+
const place = this.levels.noteDeath();
|
|
259
|
+
return this.track('death', { cause, ...(extra.enemy ? { enemy: extra.enemy } : {}), ...(place?.world ? { world: place.world } : {}), ...(place?.level ? { level: place.level } : {}) }, { position: extra.position });
|
|
260
|
+
}
|
|
261
|
+
/** An error the game caught, as the errors family tells it. */
|
|
262
|
+
error(err, extra = {}) {
|
|
263
|
+
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
264
|
+
this.bugs.log(`error ${message}`);
|
|
265
|
+
const place = this.levels.inPlay;
|
|
266
|
+
return this.track('error', {
|
|
267
|
+
kind: extra.kind ?? 'exception',
|
|
268
|
+
message: message.slice(0, 500),
|
|
269
|
+
...(extra.fingerprint ? { fingerprint: extra.fingerprint.slice(0, 100) } : {}),
|
|
270
|
+
handled: extra.handled ?? true,
|
|
271
|
+
...(place?.world ? { world: place.world } : {}),
|
|
272
|
+
...(place?.level ? { level: place.level } : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// ── Bug reports ───────────────────────────────────────────────────────────
|
|
276
|
+
/**
|
|
277
|
+
* A bug report from the player (the categories are a fixed menu). The SDK adds the platform, build,
|
|
278
|
+
* device, language, level in play, frame rate, the last 50 events and the log lines. The report is
|
|
279
|
+
* kept on this device first and sent when it can be, its screenshot right after it; a player sends at
|
|
280
|
+
* most 5 an hour and 20 a day in an app (more wait with `too_many_reports`). Refused at once when the
|
|
281
|
+
* owner turned bug reports off (`module_disabled`) or the input can never be taken (`invalid_request`).
|
|
282
|
+
*/
|
|
283
|
+
async reportBug(input) {
|
|
284
|
+
if (!this.config.moduleOn('bugReports'))
|
|
285
|
+
throw new KrtuworksError('module_disabled', 'Bug reports are off in this app.', 403);
|
|
286
|
+
const place = this.levels.inPlay;
|
|
287
|
+
const level = place ? [place.world, place.level, place.part].filter(Boolean).join('/') : null;
|
|
288
|
+
const made = await this.bugs.report(input, level);
|
|
289
|
+
this.scheduleFlush();
|
|
290
|
+
return made;
|
|
291
|
+
}
|
|
292
|
+
/** A line for the bug reports' log (kept in memory, the last 32 KB; sent only inside a report the player makes). */
|
|
293
|
+
log(message) {
|
|
294
|
+
this.bugs.log(message);
|
|
295
|
+
}
|
|
296
|
+
/** The player's bug reports in this app, newest first, with their status and the build a fix shipped in. */
|
|
297
|
+
async myBugReports() {
|
|
298
|
+
return this.bugs.mine();
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Called for each of the player's reports that was fixed since the app last looked (at a player's
|
|
302
|
+
* arrival, and on myBugReports): the game may tell the player "fixed in <fixedIn>".
|
|
303
|
+
*/
|
|
304
|
+
onBugFixed(listener) {
|
|
305
|
+
return this.bugs.onFixed(listener);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Reports another player's profile picture: `subject` is the id your app knows them by (another
|
|
309
|
+
* player of this app, e.g. from your game server), the reason one of AVATAR_REPORT_REASONS, an
|
|
310
|
+
* optional note of at most 300 characters. Krtuworks reviews it; the reporter is never told an
|
|
311
|
+
* outcome. Once per player and picture (again answers `already: true`), at most 10 a day
|
|
312
|
+
* (`too_many_reports`). Needs a connection: nothing waits on this device.
|
|
313
|
+
*/
|
|
314
|
+
async reportAvatar(subject, reason, note) {
|
|
315
|
+
const { path, json } = avatarReportBody(subject, reason, note);
|
|
316
|
+
const answer = await this.api.player(path, { method: 'POST', json });
|
|
317
|
+
return { already: answer.json?.already === true };
|
|
318
|
+
}
|
|
319
|
+
/** The player's statistics choice in this app (on by default). Off: no event is made or kept. */
|
|
320
|
+
async getTelemetryOptOut() {
|
|
321
|
+
await this.readOptOut();
|
|
322
|
+
return this.optedOut;
|
|
323
|
+
}
|
|
324
|
+
async setTelemetryOptOut(optedOut) {
|
|
325
|
+
await this.api.player('/v1/me/telemetry-opt-out', { method: 'PUT', json: { optedOut } });
|
|
326
|
+
this.optedOut = optedOut;
|
|
327
|
+
await this.store.set(this.optOutKey(), optedOut);
|
|
328
|
+
}
|
|
329
|
+
optOutKey() {
|
|
330
|
+
return `krtu:${this.options.clientId}:optout:${this.identity.player?.subject ?? ANON}`;
|
|
331
|
+
}
|
|
332
|
+
async readOptOut() {
|
|
333
|
+
const kept = await this.store.get(this.optOutKey());
|
|
334
|
+
if (kept !== undefined)
|
|
335
|
+
this.optedOut = kept;
|
|
336
|
+
if (!this.identity.player)
|
|
337
|
+
return;
|
|
338
|
+
const answer = await this.api.player('/v1/me/telemetry-opt-out');
|
|
339
|
+
this.optedOut = answer.json?.optedOut === true;
|
|
340
|
+
await this.store.set(this.optOutKey(), this.optedOut);
|
|
341
|
+
}
|
|
342
|
+
// ── Progress ──────────────────────────────────────────────────────────────
|
|
343
|
+
/** Adds to a SUM stat. Kept on the device until the server has it; never lost, never counted twice. */
|
|
344
|
+
async incrementStat(key, by = 1) {
|
|
345
|
+
await this.progress.report('add', key, by);
|
|
346
|
+
this.scheduleFlush();
|
|
347
|
+
}
|
|
348
|
+
/** Reports a value to a MAX, MIN or LATEST stat. */
|
|
349
|
+
async setStat(key, value) {
|
|
350
|
+
await this.progress.report('set', key, value);
|
|
351
|
+
this.scheduleFlush();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Submits the player's score to a leaderboard (a whole number below 10^15; `details` 1-64 bytes the game
|
|
355
|
+
* keeps with it). Kept on the device until the server has it, like a stat; the server decides what the
|
|
356
|
+
* board keeps (KEEP_BEST or FORCE_UPDATE) and mirrors to the platform itself.
|
|
357
|
+
*/
|
|
358
|
+
async submitScore(key, value, options = {}) {
|
|
359
|
+
await this.progress.submitScore(key, value, options.details);
|
|
360
|
+
this.scheduleFlush();
|
|
361
|
+
}
|
|
362
|
+
async unlockAchievement(key) {
|
|
363
|
+
await this.progress.unlock(key);
|
|
364
|
+
this.scheduleFlush();
|
|
365
|
+
await this.remindLinking('first_achievement').catch(() => undefined);
|
|
366
|
+
}
|
|
367
|
+
/** The app's stats and achievements with the player's values (names in the SDK's locale where the app has them). */
|
|
368
|
+
async getProgress() {
|
|
369
|
+
const answer = await this.api.player('/v1/me/progress', { query: { locale: this.options.locale } });
|
|
370
|
+
const view = answer.json;
|
|
371
|
+
await this.progress.learnKinds(view.stats ?? []);
|
|
372
|
+
return view;
|
|
373
|
+
}
|
|
374
|
+
/** The app's leaderboards with the player's own score and rank (names in the SDK's locale where the app has them). */
|
|
375
|
+
async getLeaderboards(options = {}) {
|
|
376
|
+
const answer = await this.api.player('/v1/me/leaderboards', { query: { locale: options.locale ?? this.options.locale } });
|
|
377
|
+
const view = answer.json;
|
|
378
|
+
await this.progress.learnBoards(view.leaderboards ?? []);
|
|
379
|
+
return view;
|
|
380
|
+
}
|
|
381
|
+
/** A leaderboard's rows in its order: from `offset`, or the window around the player. */
|
|
382
|
+
async getLeaderboardEntries(key, options = {}) {
|
|
383
|
+
const answer = await this.api.player(`/v1/leaderboards/${encodeURIComponent(key)}/entries`, {
|
|
384
|
+
query: { scope: options.scope, limit: options.limit, offset: options.offset },
|
|
385
|
+
});
|
|
386
|
+
return answer.json;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Lines achievements up with a platform's: unlocks here what the platform has, answers what to unlock
|
|
390
|
+
* there (`toPlatform`) and what support took back here that the platform still has (`toClear`). The open
|
|
391
|
+
* session goes with it: the server mirrors only with the platform account the session proved (Steam:
|
|
392
|
+
* required, `409 platform_link_mismatch {state}` when that account is not mirrored for this player).
|
|
393
|
+
*/
|
|
394
|
+
async syncPlatformAchievements(unlocked) {
|
|
395
|
+
const session = this.sessions.session ?? (this.options.sessions && this.config.moduleOn('sessions') ? await this.sessions.open() : null);
|
|
396
|
+
const answer = await this.api.player('/v1/me/progress/platform-sync', {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
json: { platform: this.options.platform, build: this.options.build, unlocked, ...(session ? { sessionId: session.id } : {}) },
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
unlockedHere: answer.json?.unlockedHere ?? [],
|
|
402
|
+
toPlatform: answer.json?.toPlatform ?? [],
|
|
403
|
+
toClear: answer.json?.toClear ?? [],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/** Progress the server refused for good (a retired stat, a wrong value): for the game's own diagnostics. */
|
|
407
|
+
get progressRefusals() {
|
|
408
|
+
return [...this.progress.refusals];
|
|
409
|
+
}
|
|
410
|
+
// ── What the player owns (the game and its DLCs) ──────────────────────────
|
|
411
|
+
/** Only a Steam game has licences the server reads from the platform today; a launch asks for them once the player is here. */
|
|
412
|
+
async refreshLicences() {
|
|
413
|
+
if (this.options.game && this.options.platform === 'STEAM')
|
|
414
|
+
await this.licences.refresh().catch(() => undefined);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The player's licences as the server last said them (kept on this device, so the last answer of an
|
|
418
|
+
* earlier launch is here when the server cannot be reached): the game (`sku` base) first, then the
|
|
419
|
+
* DLCs. `checked` marks what the server confirmed with the platform in this launch. Empty for an app
|
|
420
|
+
* that is not a game.
|
|
421
|
+
*/
|
|
422
|
+
entitlements() {
|
|
423
|
+
return this.options.game ? this.licences.list() : [];
|
|
424
|
+
}
|
|
425
|
+
/** Asks the server for the stored licences now (`GET /v1/me/entitlements`) and keeps the answer. Throws when the server cannot be reached. */
|
|
426
|
+
async refreshEntitlements() {
|
|
427
|
+
return this.options.game ? this.licences.refresh() : [];
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Whether the player owns the DLC with this product key: what the server confirmed with the platform
|
|
431
|
+
* in this launch; else the last answer the server gave (a browser has no platform client of its own
|
|
432
|
+
* to ask). False for an unknown key and for an app that is not a game.
|
|
433
|
+
*/
|
|
434
|
+
ownsDlc(key) {
|
|
435
|
+
return this.options.game && this.licences.ownsDlc(key);
|
|
436
|
+
}
|
|
437
|
+
// ── Rich presence ─────────────────────────────────────────────────────────
|
|
438
|
+
presenceOn() {
|
|
439
|
+
return this.options.game && this.config.moduleOn('presence');
|
|
440
|
+
}
|
|
441
|
+
async refreshPresence() {
|
|
442
|
+
if (this.presenceOn())
|
|
443
|
+
await this.presence.refresh().catch(() => undefined);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Shows what the player is doing (a state the owner declared on the console, with its parameters).
|
|
447
|
+
* Never throws for the reasons in the result. A browser has no platform presence to write, so a call
|
|
448
|
+
* that passes every rule answers `no_platform`. `LEVEL` and `SESSION_MINUTES` fill themselves when
|
|
449
|
+
* the state declares them and the call does not give them.
|
|
450
|
+
*/
|
|
451
|
+
async setPresence(key, params = {}) {
|
|
452
|
+
if (!this.options.game)
|
|
453
|
+
return { applied: false, reason: 'not_game' };
|
|
454
|
+
if (!this.config.moduleOn('presence'))
|
|
455
|
+
return { applied: false, reason: 'module_disabled' };
|
|
456
|
+
const reason = this.presence.check(key, params, { LEVEL: () => this.levels.inPlay?.level ?? null, SESSION_MINUTES: () => String(Math.floor(this.sessions.activeSeconds() / 60)) }, (line) => this.bugs.log(line));
|
|
457
|
+
return { applied: false, reason: reason ?? 'no_platform' };
|
|
458
|
+
}
|
|
459
|
+
/** Takes the shown presence away. A browser shows none, so there is nothing to clear. */
|
|
460
|
+
clearPresence() { }
|
|
461
|
+
/** Tells the platform the player's party (`null` leaves it). A browser has no platform presence, so this does nothing. */
|
|
462
|
+
setPresenceGroup(_groupId, _size) { }
|
|
463
|
+
/** Whether the player shows their presence; null until the server has said (or when the module is off). */
|
|
464
|
+
presenceVisible() {
|
|
465
|
+
return this.presenceOn() ? this.presence.visible() : null;
|
|
466
|
+
}
|
|
467
|
+
/** The player's choice to show their presence. Throws `module_disabled` when the owner turned presence off. */
|
|
468
|
+
async setPresenceVisible(visible) {
|
|
469
|
+
if (!this.presenceOn())
|
|
470
|
+
throw new KrtuworksError('module_disabled', 'Rich presence is off in this app.', 403);
|
|
471
|
+
await this.presence.setVisible(visible);
|
|
472
|
+
}
|
|
473
|
+
// ── The app's settings, A/B tests, status ─────────────────────────────────
|
|
474
|
+
async refreshConfig() {
|
|
475
|
+
await this.config.refresh();
|
|
476
|
+
await this.refreshPresence();
|
|
477
|
+
}
|
|
478
|
+
/** The owner's gates for this build: maintenance, a newer minimum version, a switch. */
|
|
479
|
+
gate() {
|
|
480
|
+
const config = this.config.value?.config;
|
|
481
|
+
const maintenance = config?.maintenance ?? null;
|
|
482
|
+
return {
|
|
483
|
+
maintenance: maintenance ? { enabled: maintenance.enabled === true, message: maintenance.message ?? null } : null,
|
|
484
|
+
updateRequired: typeof config?.minVersion === 'string' ? olderThan(this.options.build, config.minVersion) : false,
|
|
485
|
+
switchedOff: this.config.switchForBuild(),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/** The modules the owner left on (all of them until the config says otherwise; in an app the game-only ones are off). */
|
|
489
|
+
modules() {
|
|
490
|
+
return this.config.modules();
|
|
491
|
+
}
|
|
492
|
+
/** The player's A/B groups; their values take over the config's (`value`). */
|
|
493
|
+
async refreshExperiments() {
|
|
494
|
+
const answer = await this.api.player('/v1/me/experiments');
|
|
495
|
+
const list = answer.json?.experiments ?? [];
|
|
496
|
+
this.experiments = new Map(list.map((e) => [e.configKey, e.value]));
|
|
497
|
+
return list;
|
|
498
|
+
}
|
|
499
|
+
/** A remote config value, the player's A/B group's when one varies it. */
|
|
500
|
+
value(key, fallback) {
|
|
501
|
+
return this.experiments.has(key) ? this.experiments.get(key) : this.config.get(key, fallback);
|
|
502
|
+
}
|
|
503
|
+
async getStatus() {
|
|
504
|
+
const answer = await this.api.player('/v1/me/status', { query: { platform: this.options.platform } });
|
|
505
|
+
return answer.json ?? {};
|
|
506
|
+
}
|
|
507
|
+
// ── The player's card ─────────────────────────────────────────────────────
|
|
508
|
+
async getProfile() {
|
|
509
|
+
const answer = await this.api.player('/v1/me/profile');
|
|
510
|
+
return { fields: answer.json?.fields ?? {}, updatedAt: answer.json?.updatedAt ?? null };
|
|
511
|
+
}
|
|
512
|
+
/** Writes the named fields of the player's card (null clears one). */
|
|
513
|
+
async updateProfile(fields) {
|
|
514
|
+
const answer = await this.api.player('/v1/me/profile', { method: 'PUT', json: { fields, platform: this.options.platform, build: this.options.build } });
|
|
515
|
+
return { fields: answer.json?.fields ?? {}, updatedAt: answer.json?.updatedAt ?? null };
|
|
516
|
+
}
|
|
517
|
+
// ── A guest's own account (no e-mail, no account site: the device key proves it) ──
|
|
518
|
+
async guestCall(path, purpose, extra = {}) {
|
|
519
|
+
const assertion = await this.identity.guestProof(purpose);
|
|
520
|
+
const { body, headers } = form({ client_id: this.options.clientId, assertion, ...extra });
|
|
521
|
+
const answer = await send(this.fetchFn, this.clock, `${this.options.idOrigin}${path}`, { method: 'POST', headers, body });
|
|
522
|
+
if (answer.status >= 400)
|
|
523
|
+
throw errorOf(answer);
|
|
524
|
+
return answer.json ?? {};
|
|
525
|
+
}
|
|
526
|
+
/** Asks to delete this guest account (90 days; signing in as the guest is then only for cancelling or the data copy). */
|
|
527
|
+
async requestGuestDeletion() {
|
|
528
|
+
await this.flush().catch(() => undefined);
|
|
529
|
+
const answer = await this.guestCall('/v1/guest/deletion', 'delete', { confirm: 'true' });
|
|
530
|
+
this.sessions.close();
|
|
531
|
+
return { deletionDate: String(answer.deletionDate) };
|
|
532
|
+
}
|
|
533
|
+
async cancelGuestDeletion() {
|
|
534
|
+
const answer = await this.guestCall('/v1/guest/deletion/cancel', 'cancel_deletion');
|
|
535
|
+
return { status: String(answer.status) };
|
|
536
|
+
}
|
|
537
|
+
/** The guest's data copy: asks for a new one with `request`; a ready copy comes with a 15-minute download address. */
|
|
538
|
+
async guestDataExport(options = {}) {
|
|
539
|
+
return this.guestCall('/v1/guest/data-export', 'export_download', options.request ? { request: 'true' } : {});
|
|
540
|
+
}
|
|
541
|
+
// ── "Link a Krtuworks ID" for guests ───────────────────────
|
|
542
|
+
/**
|
|
543
|
+
* Called at the moments a guest has something to lose (the first achievement, the first save, a while
|
|
544
|
+
* of play) when the app's `linkReminder` setting says it is time: the game shows its own prompt and,
|
|
545
|
+
* if the player agrees, calls `signIn()`. Remote config `linkReminder`: `{ enabled, afterMinutes, everyDays }`
|
|
546
|
+
* (default on, 30 minutes, every 3 days).
|
|
547
|
+
*/
|
|
548
|
+
onLinkReminder(listener) {
|
|
549
|
+
this.reminderListeners.add(listener);
|
|
550
|
+
return () => this.reminderListeners.delete(listener);
|
|
551
|
+
}
|
|
552
|
+
async remindLinking(reason) {
|
|
553
|
+
const player = this.identity.player;
|
|
554
|
+
if (player?.kind !== 'guest' || !this.reminderListeners.size)
|
|
555
|
+
return;
|
|
556
|
+
const setting = (this.config.get('linkReminder') ?? {});
|
|
557
|
+
if (setting.enabled === false)
|
|
558
|
+
return;
|
|
559
|
+
const key = `krtu:${this.options.clientId}:linkReminder:${player.subject}`;
|
|
560
|
+
const state = (await this.store.get(key)) ?? { shownAt: null, achievement: false, save: false, playSeconds: 0 };
|
|
561
|
+
const active = this.sessions.activeSeconds();
|
|
562
|
+
state.playSeconds += Math.max(0, active - this.countedActive);
|
|
563
|
+
this.countedActive = active;
|
|
564
|
+
let due = false;
|
|
565
|
+
if (reason === 'first_achievement' && !state.achievement)
|
|
566
|
+
due = state.achievement = true;
|
|
567
|
+
if (reason === 'first_save' && !state.save)
|
|
568
|
+
due = state.save = true;
|
|
569
|
+
if (reason === 'play_time')
|
|
570
|
+
due = state.playSeconds >= (setting.afterMinutes ?? 30) * 60;
|
|
571
|
+
const quietMs = (setting.everyDays ?? 3) * 86_400_000;
|
|
572
|
+
const show = due && (state.shownAt === null || Date.now() - state.shownAt >= quietMs);
|
|
573
|
+
if (show)
|
|
574
|
+
state.shownAt = Date.now();
|
|
575
|
+
await this.store.set(key, state);
|
|
576
|
+
if (!show)
|
|
577
|
+
return;
|
|
578
|
+
for (const listener of this.reminderListeners) {
|
|
579
|
+
try {
|
|
580
|
+
listener(reason);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// the app's listener
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// ── Sending ───────────────────────────────────────────────────────────────
|
|
588
|
+
flushSoon = null;
|
|
589
|
+
scheduleFlush() {
|
|
590
|
+
this.flushSoon ??= setTimeout(() => {
|
|
591
|
+
this.flushSoon = null;
|
|
592
|
+
void this.flush().catch(() => undefined);
|
|
593
|
+
}, 2000);
|
|
594
|
+
this.flushSoon.unref?.();
|
|
595
|
+
}
|
|
596
|
+
flushing = null;
|
|
597
|
+
/**
|
|
598
|
+
* Sends what waits: progress first (it must not wait behind telemetry), then bug reports, then events.
|
|
599
|
+
* One send at a time: a call made while one runs gets that one's result.
|
|
600
|
+
*/
|
|
601
|
+
async flush() {
|
|
602
|
+
this.flushing ??= this.send().finally(() => {
|
|
603
|
+
this.flushing = null;
|
|
604
|
+
});
|
|
605
|
+
return this.flushing;
|
|
606
|
+
}
|
|
607
|
+
async send() {
|
|
608
|
+
await this.remindLinking('play_time').catch(() => undefined);
|
|
609
|
+
const progress = await this.progress.flush();
|
|
610
|
+
const blocked = this.config.switchForBuild()?.mode === 'ALL';
|
|
611
|
+
const bugs = blocked ? { sent: 0, dropped: [], stoppedBy: 'switched_off' } : await this.bugs.flush();
|
|
612
|
+
const events = blocked ? { sent: 0, rejected: [], stoppedBy: 'switched_off' } : await this.events.flush({ errorsOn: this.config.moduleOn('errors') });
|
|
613
|
+
return { progress, bugs, events };
|
|
614
|
+
}
|
|
615
|
+
/** @internal The validator's round: a session opened, kept alive once and ended, every step answered. */
|
|
616
|
+
async sessionRound() {
|
|
617
|
+
await this.sessions.round();
|
|
618
|
+
}
|
|
619
|
+
/** What waits on this device for the signed-in player (or the anonymous queue). */
|
|
620
|
+
async waiting() {
|
|
621
|
+
const owner = this.identity.player?.subject ?? ANON;
|
|
622
|
+
return { events: await this.events.size(owner), progress: await this.progress.waiting(owner), bugReports: await this.bugs.waiting() };
|
|
623
|
+
}
|
|
624
|
+
watchPage() {
|
|
625
|
+
if (typeof document === 'undefined' || typeof window === 'undefined')
|
|
626
|
+
return;
|
|
627
|
+
const visibility = () => {
|
|
628
|
+
const visible = document.visibilityState === 'visible';
|
|
629
|
+
this.sessions.setForeground(visible);
|
|
630
|
+
if (!visible)
|
|
631
|
+
void this.events.persist().catch(() => undefined);
|
|
632
|
+
};
|
|
633
|
+
const pagehide = () => {
|
|
634
|
+
this.levels.finish('quit');
|
|
635
|
+
void this.events.persist().catch(() => undefined);
|
|
636
|
+
void this.sessions.end();
|
|
637
|
+
};
|
|
638
|
+
const online = () => {
|
|
639
|
+
this.events.resetBackoff();
|
|
640
|
+
this.progress.resetBackoff();
|
|
641
|
+
this.bugs.resetBackoff();
|
|
642
|
+
void this.flush().catch(() => undefined);
|
|
643
|
+
};
|
|
644
|
+
document.addEventListener('visibilitychange', visibility);
|
|
645
|
+
window.addEventListener('pagehide', pagehide);
|
|
646
|
+
window.addEventListener('online', online);
|
|
647
|
+
this.detach.push(() => document.removeEventListener('visibilitychange', visibility), () => window.removeEventListener('pagehide', pagehide), () => window.removeEventListener('online', online));
|
|
648
|
+
}
|
|
649
|
+
/** Waits for the work that follows a player's arrival (tests, or a game that wants to know it is done). */
|
|
650
|
+
async ready() {
|
|
651
|
+
await this.settling;
|
|
652
|
+
}
|
|
653
|
+
/** Stops the SDK: the level in play ends as a quit, the session ends, what waits is written to the store. */
|
|
654
|
+
async shutdown() {
|
|
655
|
+
for (const t of [this.flushTimer, this.configTimer])
|
|
656
|
+
if (t)
|
|
657
|
+
clearInterval(t);
|
|
658
|
+
if (this.flushSoon)
|
|
659
|
+
clearTimeout(this.flushSoon);
|
|
660
|
+
for (const off of this.detach.splice(0))
|
|
661
|
+
off();
|
|
662
|
+
this.levels.finish('quit');
|
|
663
|
+
await this.events.persist();
|
|
664
|
+
await this.sessions.end();
|
|
665
|
+
}
|
|
666
|
+
}
|