@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/clock.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time as the server reads it: each record carries the device's wall clock, this launch's
|
|
3
|
+
* id, the time since the launch (monotonic, immune to clock changes) and, once the server has answered,
|
|
4
|
+
* how far the device clock is from the server's. The server takes the age from the monotonic time within
|
|
5
|
+
* a launch and from the offset across launches; a record is never refused for its time.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Clock {
|
|
8
|
+
readonly launchId: string;
|
|
9
|
+
private readonly started;
|
|
10
|
+
private offset;
|
|
11
|
+
/** Milliseconds since this launch began. */
|
|
12
|
+
monotonicMs(): number;
|
|
13
|
+
/** Server time minus device time, once known. */
|
|
14
|
+
get serverOffsetMs(): number | null;
|
|
15
|
+
/** The server's clock now, as well as it is known. */
|
|
16
|
+
serverNow(): number;
|
|
17
|
+
/** Learns the offset from a server timestamp (`serverTime` in an answer or the `Date` header). */
|
|
18
|
+
noteServerTime(value: string | null | undefined): void;
|
|
19
|
+
}
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { uuidV7 } from './ids.js';
|
|
2
|
+
/**
|
|
3
|
+
* Time as the server reads it: each record carries the device's wall clock, this launch's
|
|
4
|
+
* id, the time since the launch (monotonic, immune to clock changes) and, once the server has answered,
|
|
5
|
+
* how far the device clock is from the server's. The server takes the age from the monotonic time within
|
|
6
|
+
* a launch and from the offset across launches; a record is never refused for its time.
|
|
7
|
+
*/
|
|
8
|
+
export class Clock {
|
|
9
|
+
launchId = uuidV7();
|
|
10
|
+
started = performance.now();
|
|
11
|
+
offset = null;
|
|
12
|
+
/** Milliseconds since this launch began. */
|
|
13
|
+
monotonicMs() {
|
|
14
|
+
return Math.max(0, Math.round(performance.now() - this.started));
|
|
15
|
+
}
|
|
16
|
+
/** Server time minus device time, once known. */
|
|
17
|
+
get serverOffsetMs() {
|
|
18
|
+
return this.offset;
|
|
19
|
+
}
|
|
20
|
+
/** The server's clock now, as well as it is known. */
|
|
21
|
+
serverNow() {
|
|
22
|
+
return Date.now() + (this.offset ?? 0);
|
|
23
|
+
}
|
|
24
|
+
/** Learns the offset from a server timestamp (`serverTime` in an answer or the `Date` header). */
|
|
25
|
+
noteServerTime(value) {
|
|
26
|
+
if (!value)
|
|
27
|
+
return;
|
|
28
|
+
const ms = Date.parse(value);
|
|
29
|
+
if (Number.isFinite(ms))
|
|
30
|
+
this.offset = ms - Date.now();
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { KrtuStore } from './store.js';
|
|
2
|
+
export type Environment = 'TEST' | 'LIVE';
|
|
3
|
+
export type Platform = 'STEAM' | 'EPIC' | 'XBOX' | 'PLAYSTATION' | 'NINTENDO' | 'IOS' | 'ANDROID' | 'WEB' | 'WINDOWS' | 'MACOS' | 'LINUX';
|
|
4
|
+
export type AppType = 'GAME' | 'MOBILE_APP' | 'WEB_APP' | 'DESKTOP_APP';
|
|
5
|
+
/**
|
|
6
|
+
* `krtuworks-config.json`, downloaded from the app's "Credentials" page: nothing secret, only who the
|
|
7
|
+
* app is, what kind of app it is and its players' sign-in clients per environment.
|
|
8
|
+
*/
|
|
9
|
+
export interface KrtuworksAppConfig {
|
|
10
|
+
appId: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
/** The game-only parts (levels, deaths) stay off in the other kinds. A file made before the field existed is a game's. */
|
|
13
|
+
type?: AppType;
|
|
14
|
+
environments: Partial<Record<Environment, {
|
|
15
|
+
clientId?: string;
|
|
16
|
+
webClientId?: string;
|
|
17
|
+
}>>;
|
|
18
|
+
}
|
|
19
|
+
/** The modules only a game has: levels (progression) and deaths (combat). */
|
|
20
|
+
export declare const GAME_MODULES: readonly Module[];
|
|
21
|
+
export interface DeviceInfo {
|
|
22
|
+
os?: string;
|
|
23
|
+
osVersion?: string;
|
|
24
|
+
model?: string;
|
|
25
|
+
gpu?: string;
|
|
26
|
+
ramMb?: number;
|
|
27
|
+
screenWidth?: number;
|
|
28
|
+
screenHeight?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface KrtuworksOptions {
|
|
31
|
+
config: KrtuworksAppConfig;
|
|
32
|
+
environment: Environment;
|
|
33
|
+
platform: Platform;
|
|
34
|
+
/** The app's own version label (`1.4.2+381`), sent with every call. */
|
|
35
|
+
build: string;
|
|
36
|
+
/** `web` (a browser page: PKCE with an https redirect) or `native` (a desktop app: loopback redirect). Default: web in a browser. */
|
|
37
|
+
clientKind?: 'web' | 'native';
|
|
38
|
+
/** Overrides the client id the config names for the environment. */
|
|
39
|
+
clientId?: string;
|
|
40
|
+
/** Where the sign-in comes back: one of the web client's registered redirect addresses. */
|
|
41
|
+
redirectUri?: string;
|
|
42
|
+
store?: KrtuStore;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
device?: DeviceInfo;
|
|
45
|
+
/** The player's language tag (`tr`, `pt-BR`); achievement names come in it when the app has them. */
|
|
46
|
+
locale?: string;
|
|
47
|
+
/** Opens a session and keeps it alive while the app runs. Default true. */
|
|
48
|
+
sessions?: boolean;
|
|
49
|
+
idOrigin?: string;
|
|
50
|
+
devOrigin?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare const ID_ORIGIN = "https://id.krtuworks.com";
|
|
53
|
+
export declare const DEV_ORIGIN = "https://dev.krtuworks.com";
|
|
54
|
+
/** The resource a player's tokens are for (the server). */
|
|
55
|
+
export declare const DEV_RESOURCE = "https://dev.krtuworks.com";
|
|
56
|
+
export declare const ISSUER = "https://id.krtuworks.com/oidc";
|
|
57
|
+
export declare const PLAYER_SCOPE = "openid offline_access profile app:player";
|
|
58
|
+
export declare const GUEST_SCOPE = "openid offline_access app:player";
|
|
59
|
+
/** The legal documents a new guest accepts, as this SDK version knows them; a newer set answers terms_required with the list. */
|
|
60
|
+
export declare const CURRENT_TERMS = "TERMS:v1 PRIVACY:v1";
|
|
61
|
+
/** The SDK modules the owner switches per app (`config.modules`); a module that is off makes nothing. */
|
|
62
|
+
export declare const MODULES: readonly ["sessions", "progression", "combat", "economy", "items", "ui", "performance", "settings", "errors", "bugReports", "achievements", "stats", "cloudSave", "remoteConfig", "abTests", "customEvents", "presence"];
|
|
63
|
+
export type Module = (typeof MODULES)[number];
|
|
64
|
+
/** Which module a ready-made event family belongs to; any other type is the app's own (customEvents). */
|
|
65
|
+
export declare const FAMILY_MODULE: Readonly<Record<string, Module>>;
|
|
66
|
+
export declare function moduleOfEventType(type: string): Module;
|
|
67
|
+
export interface ResolvedOptions {
|
|
68
|
+
appId: string;
|
|
69
|
+
slug: string;
|
|
70
|
+
type: AppType;
|
|
71
|
+
/** The app is a game (type GAME): levels and deaths are kept. */
|
|
72
|
+
game: boolean;
|
|
73
|
+
environment: Environment;
|
|
74
|
+
platform: Platform;
|
|
75
|
+
build: string;
|
|
76
|
+
clientId: string;
|
|
77
|
+
clientKind: 'web' | 'native';
|
|
78
|
+
redirectUri: string | null;
|
|
79
|
+
idOrigin: string;
|
|
80
|
+
devOrigin: string;
|
|
81
|
+
device: DeviceInfo | null;
|
|
82
|
+
locale: string | null;
|
|
83
|
+
sessions: boolean;
|
|
84
|
+
}
|
|
85
|
+
export declare function resolveOptions(options: KrtuworksOptions): ResolvedOptions;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The modules only a game has: levels (progression) and deaths (combat). */
|
|
2
|
+
export const GAME_MODULES = ['progression', 'combat'];
|
|
3
|
+
export const ID_ORIGIN = 'https://id.krtuworks.com';
|
|
4
|
+
export const DEV_ORIGIN = 'https://dev.krtuworks.com';
|
|
5
|
+
/** The resource a player's tokens are for (the server). */
|
|
6
|
+
export const DEV_RESOURCE = 'https://dev.krtuworks.com';
|
|
7
|
+
export const ISSUER = 'https://id.krtuworks.com/oidc';
|
|
8
|
+
export const PLAYER_SCOPE = 'openid offline_access profile app:player';
|
|
9
|
+
export const GUEST_SCOPE = 'openid offline_access app:player';
|
|
10
|
+
/** The legal documents a new guest accepts, as this SDK version knows them; a newer set answers terms_required with the list. */
|
|
11
|
+
export const CURRENT_TERMS = 'TERMS:v1 PRIVACY:v1';
|
|
12
|
+
/** The SDK modules the owner switches per app (`config.modules`); a module that is off makes nothing. */
|
|
13
|
+
export const MODULES = [
|
|
14
|
+
'sessions', 'progression', 'combat', 'economy', 'items', 'ui', 'performance', 'settings', 'errors',
|
|
15
|
+
'bugReports', 'achievements', 'stats', 'cloudSave', 'remoteConfig', 'abTests', 'customEvents', 'presence',
|
|
16
|
+
];
|
|
17
|
+
/** Which module a ready-made event family belongs to; any other type is the app's own (customEvents). */
|
|
18
|
+
export const FAMILY_MODULE = {
|
|
19
|
+
screen_view: 'ui',
|
|
20
|
+
button: 'ui',
|
|
21
|
+
tutorial: 'ui',
|
|
22
|
+
progression: 'progression',
|
|
23
|
+
death: 'combat',
|
|
24
|
+
damage: 'combat',
|
|
25
|
+
economy: 'economy',
|
|
26
|
+
balance: 'economy',
|
|
27
|
+
item: 'items',
|
|
28
|
+
interaction: 'items',
|
|
29
|
+
performance: 'performance',
|
|
30
|
+
settings: 'settings',
|
|
31
|
+
error: 'errors',
|
|
32
|
+
};
|
|
33
|
+
export function moduleOfEventType(type) {
|
|
34
|
+
return Object.prototype.hasOwnProperty.call(FAMILY_MODULE, type) ? FAMILY_MODULE[type] : 'customEvents';
|
|
35
|
+
}
|
|
36
|
+
const BUILD = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
37
|
+
export function resolveOptions(options) {
|
|
38
|
+
const { config, environment } = options;
|
|
39
|
+
if (!config || typeof config.appId !== 'string' || typeof config.slug !== 'string')
|
|
40
|
+
throw new TypeError('config must be the app\'s krtuworks-config.json');
|
|
41
|
+
if (environment !== 'TEST' && environment !== 'LIVE')
|
|
42
|
+
throw new TypeError('environment is TEST or LIVE');
|
|
43
|
+
if (typeof options.build !== 'string' || !BUILD.test(options.build))
|
|
44
|
+
throw new TypeError('build is the app\'s version label, like 1.4.2+381');
|
|
45
|
+
const clientKind = options.clientKind ?? (typeof window === 'undefined' ? 'native' : 'web');
|
|
46
|
+
const env = config.environments?.[environment] ?? {};
|
|
47
|
+
const clientId = options.clientId ?? (clientKind === 'web' ? env.webClientId : env.clientId);
|
|
48
|
+
if (!clientId)
|
|
49
|
+
throw new TypeError(`krtuworks-config.json names no ${clientKind} client for ${environment}`);
|
|
50
|
+
const type = config.type || 'GAME';
|
|
51
|
+
return {
|
|
52
|
+
appId: config.appId,
|
|
53
|
+
slug: config.slug,
|
|
54
|
+
type,
|
|
55
|
+
game: type === 'GAME',
|
|
56
|
+
environment,
|
|
57
|
+
platform: options.platform,
|
|
58
|
+
build: options.build,
|
|
59
|
+
clientId,
|
|
60
|
+
clientKind,
|
|
61
|
+
redirectUri: options.redirectUri ?? null,
|
|
62
|
+
idOrigin: (options.idOrigin ?? ID_ORIGIN).replace(/\/+$/, ''),
|
|
63
|
+
devOrigin: (options.devOrigin ?? DEV_ORIGIN).replace(/\/+$/, ''),
|
|
64
|
+
device: options.device ?? null,
|
|
65
|
+
locale: options.locale ?? null,
|
|
66
|
+
sessions: options.sessions ?? true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The four members of a P-256 public key, exactly what the server accepts in a proof's header. */
|
|
2
|
+
export interface PublicDeviceKey {
|
|
3
|
+
kty: 'EC';
|
|
4
|
+
crv: 'P-256';
|
|
5
|
+
x: string;
|
|
6
|
+
y: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The device's own key: a P-256 pair made here, the private half non-extractable,
|
|
10
|
+
* kept in the store as a CryptoKey. A guest's account is bound to it; it never leaves the device.
|
|
11
|
+
*/
|
|
12
|
+
export interface DeviceKey {
|
|
13
|
+
privateKey: CryptoKey;
|
|
14
|
+
publicJwk: PublicDeviceKey;
|
|
15
|
+
/** RFC 7638 thumbprint (SHA-256, base64url) of the public key. */
|
|
16
|
+
thumbprint: string;
|
|
17
|
+
}
|
|
18
|
+
export type ProofPurpose = 'login' | 'promote' | 'delete' | 'cancel_deletion' | 'export_download';
|
|
19
|
+
export declare function jwkThumbprint(jwk: PublicDeviceKey): Promise<string>;
|
|
20
|
+
export declare function createDeviceKey(): Promise<DeviceKey>;
|
|
21
|
+
/**
|
|
22
|
+
* The compact JWS a device-key call carries: header `{ alg: ES256, typ: krtu-device-key+jwt, jwk }`,
|
|
23
|
+
* claims `{ nonce, client_id, purpose, key_thumbprint, iat }`. WebCrypto's ECDSA signature is already the
|
|
24
|
+
* raw r||s form JWS wants. `iat` is the server's clock as far as it is known: a device whose clock is
|
|
25
|
+
* off still signs inside the server's minute.
|
|
26
|
+
*/
|
|
27
|
+
export declare function signProof(key: DeviceKey, claims: {
|
|
28
|
+
nonce: string;
|
|
29
|
+
clientId: string;
|
|
30
|
+
purpose: ProofPurpose;
|
|
31
|
+
iat: number;
|
|
32
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { base64Url, sha256 } from './ids.js';
|
|
2
|
+
export async function jwkThumbprint(jwk) {
|
|
3
|
+
// RFC 7638: the required members only, in lexicographic order, no whitespace.
|
|
4
|
+
return base64Url(await sha256(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })));
|
|
5
|
+
}
|
|
6
|
+
export async function createDeviceKey() {
|
|
7
|
+
const pair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']));
|
|
8
|
+
const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
|
9
|
+
if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string')
|
|
10
|
+
throw new Error('the platform made no P-256 public key');
|
|
11
|
+
const publicJwk = { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
|
12
|
+
return { privateKey: pair.privateKey, publicJwk, thumbprint: await jwkThumbprint(publicJwk) };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The compact JWS a device-key call carries: header `{ alg: ES256, typ: krtu-device-key+jwt, jwk }`,
|
|
16
|
+
* claims `{ nonce, client_id, purpose, key_thumbprint, iat }`. WebCrypto's ECDSA signature is already the
|
|
17
|
+
* raw r||s form JWS wants. `iat` is the server's clock as far as it is known: a device whose clock is
|
|
18
|
+
* off still signs inside the server's minute.
|
|
19
|
+
*/
|
|
20
|
+
export async function signProof(key, claims) {
|
|
21
|
+
const header = base64Url(JSON.stringify({ alg: 'ES256', typ: 'krtu-device-key+jwt', jwk: key.publicJwk }));
|
|
22
|
+
const payload = base64Url(JSON.stringify({ nonce: claims.nonce, client_id: claims.clientId, purpose: claims.purpose, key_thumbprint: key.thumbprint, iat: claims.iat }));
|
|
23
|
+
const input = `${header}.${payload}`;
|
|
24
|
+
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, new TextEncoder().encode(input));
|
|
25
|
+
return `${input}.${base64Url(signature)}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Api } from './api.js';
|
|
2
|
+
import type { KrtuStore } from './store.js';
|
|
3
|
+
/** One licence of the player in this app: the game itself (`sku` base) or one of its DLCs (the DLC's product key). */
|
|
4
|
+
export interface Entitlement {
|
|
5
|
+
sku: string;
|
|
6
|
+
source: string;
|
|
7
|
+
/** null: the server never saw a licence (a DLC the player never owned). */
|
|
8
|
+
status: 'ACTIVE' | 'REVOKED' | null;
|
|
9
|
+
/** The platform's id the licence is read for (a Steam AppID); null when the server has none. */
|
|
10
|
+
platformProductId: string | null;
|
|
11
|
+
/** The server checked it with the platform in this launch (the session's answer). */
|
|
12
|
+
checked: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What the player owns (the server `entitlements`): the game's licence and its DLCs, as the server last said.
|
|
16
|
+
* Kept per player and environment in the store (the last answer of an earlier launch stays when the
|
|
17
|
+
* server cannot be reached). `ownsDlc` reads, in this order: what the server confirmed with the
|
|
18
|
+
* platform in this launch (a session answer item with `checked`), then the platform client's own answer
|
|
19
|
+
* (a browser has none), then the last answer the server gave, kept from an earlier launch or fetched.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Entitlements {
|
|
22
|
+
private readonly clientId;
|
|
23
|
+
private readonly store;
|
|
24
|
+
private readonly api;
|
|
25
|
+
private readonly subject;
|
|
26
|
+
private cached;
|
|
27
|
+
constructor(clientId: string, store: KrtuStore, api: Api, subject: () => string | null);
|
|
28
|
+
private key;
|
|
29
|
+
/** Reads the current player's kept list. */
|
|
30
|
+
load(): Promise<void>;
|
|
31
|
+
/** Reads the kept list unless this player's is already held (a launch's confirmed answers stay). */
|
|
32
|
+
ensureLoaded(): Promise<void>;
|
|
33
|
+
/** The player changed or signed out: nothing of the old player stays in memory. */
|
|
34
|
+
forget(): void;
|
|
35
|
+
private current;
|
|
36
|
+
/** The licences as known now: the server's last answer, with `checked` on what it confirmed in this launch. */
|
|
37
|
+
list(): Entitlement[];
|
|
38
|
+
private keep;
|
|
39
|
+
/** The session answer's list: its checked items are this launch's confirmed answers, the whole list the server's last word. */
|
|
40
|
+
fromSession(value: unknown): Promise<void>;
|
|
41
|
+
/** `GET /v1/me/entitlements`: the stored licences replace what the kept list says (a DLC it no longer holds is kept as `null`, for its id). */
|
|
42
|
+
refresh(): Promise<Entitlement[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the player owns the DLC `key` (its product key). `local` is the platform client's own
|
|
45
|
+
* answer for the DLC's platform id (Steam's), when the platform has one.
|
|
46
|
+
*/
|
|
47
|
+
ownsDlc(key: string, local?: (platformProductId: string) => boolean | null): boolean;
|
|
48
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Reads the server's items (`entitlements` of a session answer, the list of `GET /v1/me/entitlements`); anything else is ignored. */
|
|
2
|
+
function itemsOf(value) {
|
|
3
|
+
if (!Array.isArray(value))
|
|
4
|
+
return [];
|
|
5
|
+
const items = [];
|
|
6
|
+
for (const raw of value) {
|
|
7
|
+
if (!raw || typeof raw.sku !== 'string' || typeof raw.source !== 'string')
|
|
8
|
+
continue;
|
|
9
|
+
items.push({
|
|
10
|
+
sku: raw.sku,
|
|
11
|
+
source: raw.source,
|
|
12
|
+
status: raw.status === 'ACTIVE' || raw.status === 'REVOKED' ? raw.status : null,
|
|
13
|
+
platformProductId: typeof raw.platformProductId === 'string' ? raw.platformProductId : null,
|
|
14
|
+
checked: raw.checked === true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return items;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What the player owns (the server `entitlements`): the game's licence and its DLCs, as the server last said.
|
|
21
|
+
* Kept per player and environment in the store (the last answer of an earlier launch stays when the
|
|
22
|
+
* server cannot be reached). `ownsDlc` reads, in this order: what the server confirmed with the
|
|
23
|
+
* platform in this launch (a session answer item with `checked`), then the platform client's own answer
|
|
24
|
+
* (a browser has none), then the last answer the server gave, kept from an earlier launch or fetched.
|
|
25
|
+
*/
|
|
26
|
+
export class Entitlements {
|
|
27
|
+
clientId;
|
|
28
|
+
store;
|
|
29
|
+
api;
|
|
30
|
+
subject;
|
|
31
|
+
cached = null;
|
|
32
|
+
constructor(clientId, store, api, subject) {
|
|
33
|
+
this.clientId = clientId;
|
|
34
|
+
this.store = store;
|
|
35
|
+
this.api = api;
|
|
36
|
+
this.subject = subject;
|
|
37
|
+
}
|
|
38
|
+
key(subject) {
|
|
39
|
+
return `krtu:${this.clientId}:entitlements:${subject}`;
|
|
40
|
+
}
|
|
41
|
+
/** Reads the current player's kept list. */
|
|
42
|
+
async load() {
|
|
43
|
+
const subject = this.subject();
|
|
44
|
+
this.cached = subject ? { subject, stored: (await this.store.get(this.key(subject))) ?? [], confirmed: new Map() } : null;
|
|
45
|
+
}
|
|
46
|
+
/** Reads the kept list unless this player's is already held (a launch's confirmed answers stay). */
|
|
47
|
+
async ensureLoaded() {
|
|
48
|
+
if (this.subject() && this.cached?.subject !== this.subject())
|
|
49
|
+
await this.load();
|
|
50
|
+
}
|
|
51
|
+
/** The player changed or signed out: nothing of the old player stays in memory. */
|
|
52
|
+
forget() {
|
|
53
|
+
this.cached = null;
|
|
54
|
+
}
|
|
55
|
+
current() {
|
|
56
|
+
return this.cached && this.cached.subject === this.subject() ? this.cached : null;
|
|
57
|
+
}
|
|
58
|
+
/** The licences as known now: the server's last answer, with `checked` on what it confirmed in this launch. */
|
|
59
|
+
list() {
|
|
60
|
+
const held = this.current();
|
|
61
|
+
return held ? held.stored.map((s) => held.confirmed.get(s.sku) ?? { ...s, checked: false }) : [];
|
|
62
|
+
}
|
|
63
|
+
async keep(held, stored) {
|
|
64
|
+
held.stored = stored;
|
|
65
|
+
await this.store.set(this.key(held.subject), stored);
|
|
66
|
+
}
|
|
67
|
+
/** The session answer's list: its checked items are this launch's confirmed answers, the whole list the server's last word. */
|
|
68
|
+
async fromSession(value) {
|
|
69
|
+
const subject = this.subject();
|
|
70
|
+
const items = itemsOf(value);
|
|
71
|
+
if (!subject || !items.length)
|
|
72
|
+
return;
|
|
73
|
+
await this.ensureLoaded();
|
|
74
|
+
const held = this.current();
|
|
75
|
+
if (!held)
|
|
76
|
+
return;
|
|
77
|
+
held.confirmed = new Map(items.filter((i) => i.checked).map((i) => [i.sku, i]));
|
|
78
|
+
await this.keep(held, items.map(({ checked: _checked, ...rest }) => rest));
|
|
79
|
+
}
|
|
80
|
+
/** `GET /v1/me/entitlements`: the stored licences replace what the kept list says (a DLC it no longer holds is kept as `null`, for its id). */
|
|
81
|
+
async refresh() {
|
|
82
|
+
const subject = this.subject();
|
|
83
|
+
if (!subject)
|
|
84
|
+
return [];
|
|
85
|
+
await this.ensureLoaded();
|
|
86
|
+
const answer = await this.api.player('/v1/me/entitlements');
|
|
87
|
+
const held = this.current();
|
|
88
|
+
if (!held)
|
|
89
|
+
return [];
|
|
90
|
+
const items = itemsOf(answer.json?.entitlements);
|
|
91
|
+
const listed = new Set(items.map((i) => i.sku));
|
|
92
|
+
const unlisted = held.stored.filter((s) => !listed.has(s.sku)).map((s) => ({ ...s, status: null }));
|
|
93
|
+
await this.keep(held, [...items.map(({ checked: _checked, ...rest }) => rest), ...unlisted]);
|
|
94
|
+
return this.list();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the player owns the DLC `key` (its product key). `local` is the platform client's own
|
|
98
|
+
* answer for the DLC's platform id (Steam's), when the platform has one.
|
|
99
|
+
*/
|
|
100
|
+
ownsDlc(key, local) {
|
|
101
|
+
const held = this.current();
|
|
102
|
+
const kept = held?.stored.find((s) => s.sku === key);
|
|
103
|
+
const confirmed = held?.confirmed.get(key);
|
|
104
|
+
if (confirmed)
|
|
105
|
+
return confirmed.status === 'ACTIVE';
|
|
106
|
+
if (local && kept?.platformProductId) {
|
|
107
|
+
const said = local(kept.platformProductId);
|
|
108
|
+
if (said !== null)
|
|
109
|
+
return said;
|
|
110
|
+
}
|
|
111
|
+
return kept?.status === 'ACTIVE';
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure the SDK reports. `code` is the server's code (the server `{ error: { code } }`, Krtuworks ID's
|
|
3
|
+
* OAuth `error`), or one of the SDK's own:
|
|
4
|
+
* - network_error: no answer (offline, DNS, a dropped connection); the call may or may not have arrived
|
|
5
|
+
* - context_changed: the account or environment changed while the call ran; its answer was dropped
|
|
6
|
+
* - signed_out: the call needs a player and none is signed in
|
|
7
|
+
* - switched_off: the owner switched this build off (`message` says why)
|
|
8
|
+
*/
|
|
9
|
+
export declare class KrtuworksError extends Error {
|
|
10
|
+
/** HTTP status; 0 when nothing came back. */
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly code: string;
|
|
13
|
+
/** Seconds to wait before trying again, when the server said so. */
|
|
14
|
+
readonly retryAfter: number | null;
|
|
15
|
+
/** The rest of the server's answer (`mergedInto`, `current`, `endsAt`, `terms` …). */
|
|
16
|
+
readonly details: Record<string, unknown>;
|
|
17
|
+
constructor(code: string, message: string, status?: number, retryAfter?: number | null, details?: Record<string, unknown>);
|
|
18
|
+
/** True when trying the same call again later can succeed (no answer, a busy or briefly closed server). */
|
|
19
|
+
get retryable(): boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function isKrtuworksError(err: unknown, code?: string): err is KrtuworksError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure the SDK reports. `code` is the server's code (the server `{ error: { code } }`, Krtuworks ID's
|
|
3
|
+
* OAuth `error`), or one of the SDK's own:
|
|
4
|
+
* - network_error: no answer (offline, DNS, a dropped connection); the call may or may not have arrived
|
|
5
|
+
* - context_changed: the account or environment changed while the call ran; its answer was dropped
|
|
6
|
+
* - signed_out: the call needs a player and none is signed in
|
|
7
|
+
* - switched_off: the owner switched this build off (`message` says why)
|
|
8
|
+
*/
|
|
9
|
+
export class KrtuworksError extends Error {
|
|
10
|
+
/** HTTP status; 0 when nothing came back. */
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
/** Seconds to wait before trying again, when the server said so. */
|
|
14
|
+
retryAfter;
|
|
15
|
+
/** The rest of the server's answer (`mergedInto`, `current`, `endsAt`, `terms` …). */
|
|
16
|
+
details;
|
|
17
|
+
constructor(code, message, status = 0, retryAfter = null, details = {}) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'KrtuworksError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.retryAfter = retryAfter;
|
|
23
|
+
this.details = details;
|
|
24
|
+
}
|
|
25
|
+
/** True when trying the same call again later can succeed (no answer, a busy or briefly closed server). */
|
|
26
|
+
get retryable() {
|
|
27
|
+
return this.status === 0 || this.status === 429 || this.status >= 500 || this.code === 'account_merging';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function isKrtuworksError(err, code) {
|
|
31
|
+
return err instanceof KrtuworksError && (code === undefined || err.code === code);
|
|
32
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Api } from './api.js';
|
|
2
|
+
import type { Clock } from './clock.js';
|
|
3
|
+
import type { ResolvedOptions } from './config.js';
|
|
4
|
+
import type { KrtuStore } from './store.js';
|
|
5
|
+
export interface EventPosition {
|
|
6
|
+
space: 'world' | 'screen';
|
|
7
|
+
area: string;
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
z?: number;
|
|
11
|
+
}
|
|
12
|
+
/** An event as `/v1/events` takes it. */
|
|
13
|
+
export interface WireEvent {
|
|
14
|
+
id: string;
|
|
15
|
+
type: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
launchId: string;
|
|
18
|
+
monotonicMs: number;
|
|
19
|
+
serverOffsetMs?: number;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
build?: string;
|
|
22
|
+
schemaVersion?: number;
|
|
23
|
+
position?: EventPosition;
|
|
24
|
+
data?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export declare const ANON = "anon";
|
|
27
|
+
export interface EventQueueContext {
|
|
28
|
+
opts: ResolvedOptions;
|
|
29
|
+
store: KrtuStore;
|
|
30
|
+
clock: Clock;
|
|
31
|
+
api: Api;
|
|
32
|
+
/** Who the events are sent as now (null: nobody signed in). */
|
|
33
|
+
subject: () => string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface FlushResult {
|
|
36
|
+
sent: number;
|
|
37
|
+
rejected: Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
code: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** Why the queue stopped early (network_error, switched_off …), null when it emptied. */
|
|
42
|
+
stoppedBy: string | null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Telemetry events: every event is made with its id and
|
|
46
|
+
* bound to the player it is about (`anon` before any account; that queue goes to the first player).
|
|
47
|
+
* Queued events are written to the store before they are sent; a batch is frozen before its first send
|
|
48
|
+
* and sent again unchanged after a failure; each event's answer is read on its own: accepted, duplicate
|
|
49
|
+
* and rejected leave the queue, retry goes back with its id into a new batch.
|
|
50
|
+
*/
|
|
51
|
+
export declare class EventQueue {
|
|
52
|
+
private readonly ctx;
|
|
53
|
+
private buffer;
|
|
54
|
+
private persistTimer;
|
|
55
|
+
private notBefore;
|
|
56
|
+
private backoffMs;
|
|
57
|
+
constructor(ctx: EventQueueContext);
|
|
58
|
+
private key;
|
|
59
|
+
private lock;
|
|
60
|
+
private load;
|
|
61
|
+
private save;
|
|
62
|
+
/** Makes an event now, for whoever plays now. Kept in memory for a moment, then written to the store. */
|
|
63
|
+
add(event: Omit<WireEvent, 'id' | 'createdAt' | 'launchId' | 'monotonicMs' | 'serverOffsetMs'>): WireEvent;
|
|
64
|
+
/** Writes the events made since the last write; drops the oldest when the queue is full or too old. */
|
|
65
|
+
persist(): Promise<void>;
|
|
66
|
+
private trim;
|
|
67
|
+
/** Events waiting for `owner` (written and not yet written). */
|
|
68
|
+
size(owner: string): Promise<number>;
|
|
69
|
+
/**
|
|
70
|
+
* Moves `from`'s queue to `to`: the events made before any account go to the first player; a merged
|
|
71
|
+
* account's go to the one it went into (the server counts each event once by its id, and a batch it
|
|
72
|
+
* took under the old account answers the same under the new one).
|
|
73
|
+
*/
|
|
74
|
+
move(from: string, to: string): Promise<void>;
|
|
75
|
+
private freeze;
|
|
76
|
+
/** The dropped-events count, said once as an SDK error event (if the app keeps errors). */
|
|
77
|
+
private droppedNotice;
|
|
78
|
+
/**
|
|
79
|
+
* Sends the signed-in player's queue, batch by batch, until it is empty or a send fails (the frozen
|
|
80
|
+
* batch then waits, unchanged, for the next flush).
|
|
81
|
+
*/
|
|
82
|
+
flush(options?: {
|
|
83
|
+
errorsOn: boolean;
|
|
84
|
+
maxBatches?: number;
|
|
85
|
+
}): Promise<FlushResult>;
|
|
86
|
+
private sendBatch;
|
|
87
|
+
/** A batch never taken by the server goes back to the front of the queue (only before any answer said otherwise). */
|
|
88
|
+
private unfreeze;
|
|
89
|
+
private failed;
|
|
90
|
+
private backoff;
|
|
91
|
+
/** Lets the next flush try at once (back online, or the player changed). */
|
|
92
|
+
resetBackoff(): void;
|
|
93
|
+
}
|