@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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Krtuworks } from './client.js';
|
|
2
|
+
import { CURRENT_TERMS } from './config.js';
|
|
3
|
+
import { isKrtuworksError } from './errors.js';
|
|
4
|
+
const APP_ID = /^app_[0-9a-z]{20}$/;
|
|
5
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/;
|
|
6
|
+
const BUILD = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
7
|
+
const TYPES = ['GAME', 'MOBILE_APP', 'WEB_APP', 'DESKTOP_APP'];
|
|
8
|
+
const PLATFORMS = ['STEAM', 'EPIC', 'XBOX', 'PLAYSTATION', 'NINTENDO', 'IOS', 'ANDROID', 'WEB', 'WINDOWS', 'MACOS', 'LINUX'];
|
|
9
|
+
const SENDS = 5;
|
|
10
|
+
/** The server takes one write per slot per 10 s; the validator sits out a wait of at most WRITE_WAIT_S. */
|
|
11
|
+
const WRITE_LIMIT_S = 10;
|
|
12
|
+
const WRITE_WAIT_S = 15;
|
|
13
|
+
/** The event the validator sends: the first family whose module is on. */
|
|
14
|
+
const PROBES = [
|
|
15
|
+
{ module: 'ui', type: 'screen_view', data: { screen: 'krtuworks-validator' } },
|
|
16
|
+
{ module: 'performance', type: 'performance', data: { fps: 60 } },
|
|
17
|
+
{ module: 'settings', type: 'settings', data: { language: 'en' } },
|
|
18
|
+
{ module: 'errors', type: 'error', data: { kind: 'other', message: 'Krtuworks validator', handled: true } },
|
|
19
|
+
{ module: 'economy', type: 'balance', data: { currency: 'validator', amount: 0 } },
|
|
20
|
+
{ module: 'items', type: 'interaction', data: { kind: 'other', name: 'krtuworks-validator' } },
|
|
21
|
+
{ module: 'progression', type: 'progression', data: { action: 'checkpoint', level: 'krtuworks-validator', checkpoint: 'validator' } },
|
|
22
|
+
{ module: 'combat', type: 'damage', data: { amount: 0 } },
|
|
23
|
+
];
|
|
24
|
+
const TITLES = {
|
|
25
|
+
config: 'krtuworks-config.json names the app and its sign-in clients',
|
|
26
|
+
build: 'The build is a version label the servers take',
|
|
27
|
+
platform: 'The platform and the sign-in return are set',
|
|
28
|
+
sign_in: 'Signed in as a TEST player',
|
|
29
|
+
session: 'Opened a session, sent a heartbeat and ended it',
|
|
30
|
+
events: 'An event batch was accepted',
|
|
31
|
+
remote_config: 'Read the remote config',
|
|
32
|
+
cloud_save: 'Wrote a save and read it back',
|
|
33
|
+
progress: 'Sent a progress operation',
|
|
34
|
+
bug_report: 'Sent a test bug report',
|
|
35
|
+
sentry: 'A test error reached Sentry',
|
|
36
|
+
};
|
|
37
|
+
const ORDER = Object.keys(TITLES);
|
|
38
|
+
const RUN = ['sign_in', 'session', 'events', 'remote_config', 'cloud_save', 'progress', 'bug_report'];
|
|
39
|
+
const check = (key, status, detail = null) => ({ key, title: TITLES[key], status, detail });
|
|
40
|
+
/** What is wrong with krtuworks-config.json for this kind of client, if anything. */
|
|
41
|
+
export function configProblems(config, clientKind) {
|
|
42
|
+
if (!config || typeof config !== 'object')
|
|
43
|
+
return ['the config is missing'];
|
|
44
|
+
const problems = [];
|
|
45
|
+
if (!APP_ID.test(String(config.appId)))
|
|
46
|
+
problems.push('appId is not an app_ id');
|
|
47
|
+
if (!SLUG.test(String(config.slug)))
|
|
48
|
+
problems.push("slug is not the app's address name");
|
|
49
|
+
if (config.type !== undefined && !TYPES.includes(config.type))
|
|
50
|
+
problems.push(`type is one of ${TYPES.join(', ')}`);
|
|
51
|
+
const field = clientKind === 'web' ? 'webClientId' : 'clientId';
|
|
52
|
+
for (const env of ['TEST', 'LIVE']) {
|
|
53
|
+
const id = config.environments?.[env]?.[field];
|
|
54
|
+
const want = `${config.slug}.${env.toLowerCase()}.${clientKind}`;
|
|
55
|
+
if (!id)
|
|
56
|
+
problems.push(`environments.${env} has no ${field}`);
|
|
57
|
+
else if (id !== want)
|
|
58
|
+
problems.push(`environments.${env}.${field} is ${id}, not ${want}`);
|
|
59
|
+
}
|
|
60
|
+
return problems;
|
|
61
|
+
}
|
|
62
|
+
/** What is wrong with the platform and, for a web page, the sign-in return address, if anything. */
|
|
63
|
+
export function platformProblems(platform, clientKind, redirectUri) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
if (!PLATFORMS.includes(platform))
|
|
66
|
+
problems.push(`platform is one of ${PLATFORMS.join(', ')}`);
|
|
67
|
+
if (clientKind === 'web') {
|
|
68
|
+
let url = null;
|
|
69
|
+
try {
|
|
70
|
+
url = redirectUri ? new URL(redirectUri) : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
url = null;
|
|
74
|
+
}
|
|
75
|
+
if (url?.protocol !== 'https:')
|
|
76
|
+
problems.push("redirectUri is one of the web client's registered https return addresses");
|
|
77
|
+
}
|
|
78
|
+
return problems;
|
|
79
|
+
}
|
|
80
|
+
function why(err) {
|
|
81
|
+
if (isKrtuworksError(err))
|
|
82
|
+
return `${err.code}: ${err.message}`;
|
|
83
|
+
return err instanceof Error ? err.message : String(err);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sends until nothing waits (another send may be running when this starts) and adds up what came back,
|
|
87
|
+
* with what still waits at the end: the game's own SDK on the same store may have sent the validator's
|
|
88
|
+
* item first, so "left this device unrefused" counts as sent.
|
|
89
|
+
*/
|
|
90
|
+
async function sendAll(krtu) {
|
|
91
|
+
const total = {
|
|
92
|
+
progress: { applied: 0, stoppedBy: null, values: {} },
|
|
93
|
+
bugs: { sent: 0, dropped: [] },
|
|
94
|
+
events: { sent: 0, rejected: [], stoppedBy: null },
|
|
95
|
+
};
|
|
96
|
+
let left = { events: 0, progress: 0, bugReports: 0 };
|
|
97
|
+
for (let round = 0; round < SENDS; round += 1) {
|
|
98
|
+
const r = await krtu.flush();
|
|
99
|
+
total.progress.applied += r.progress.applied;
|
|
100
|
+
total.progress.stoppedBy = r.progress.stoppedBy;
|
|
101
|
+
total.bugs.sent += r.bugs.sent;
|
|
102
|
+
total.bugs.dropped.push(...r.bugs.dropped);
|
|
103
|
+
total.bugs.stoppedBy = r.bugs.stoppedBy;
|
|
104
|
+
total.events.sent += r.events.sent;
|
|
105
|
+
total.events.rejected.push(...r.events.rejected);
|
|
106
|
+
total.events.stoppedBy = r.events.stoppedBy;
|
|
107
|
+
left = await krtu.waiting();
|
|
108
|
+
if (!left.events && !left.progress && !left.bugReports)
|
|
109
|
+
break;
|
|
110
|
+
if (r.progress.stoppedBy || r.bugs.stoppedBy || r.events.stoppedBy)
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
return { ...total, left };
|
|
114
|
+
}
|
|
115
|
+
/** A send's verdict: sent, or left this device with no refusal; otherwise why it was not. */
|
|
116
|
+
export const sentOrWhy = (sent, waiting, stoppedBy) => sent > 0 || (!waiting && !stoppedBy) ? null : (stoppedBy ?? 'not sent');
|
|
117
|
+
/** Checks this app's setup and runs a TEST round against the servers; never throws. */
|
|
118
|
+
export async function validate(options) {
|
|
119
|
+
const clientKind = options.clientKind ?? (typeof window === 'undefined' ? 'native' : 'web');
|
|
120
|
+
const build = String(options.build);
|
|
121
|
+
const results = new Map();
|
|
122
|
+
const report = () => {
|
|
123
|
+
const checks = ORDER.map((key) => results.get(key) ?? check(key, 'SKIPPED', 'Not reached.'));
|
|
124
|
+
return { ok: !checks.some((c) => c.status === 'FAILED'), build, checks };
|
|
125
|
+
};
|
|
126
|
+
const config = configProblems(options.config, clientKind);
|
|
127
|
+
results.set('config', check('config', config.length ? 'FAILED' : 'PASSED', config.length ? config.join('; ') : null));
|
|
128
|
+
const buildOk = BUILD.test(build);
|
|
129
|
+
results.set('build', check('build', buildOk ? 'PASSED' : 'FAILED', buildOk ? null : 'build is the app version, like 1.4.2 or 1.4.2+381 (letters, digits, . _ + -, at most 64)'));
|
|
130
|
+
const platform = platformProblems(options.platform, clientKind, options.redirectUri);
|
|
131
|
+
results.set('platform', check('platform', platform.length ? 'FAILED' : 'PASSED', platform.length ? platform.join('; ') : null));
|
|
132
|
+
results.set('sentry', check('sentry', 'SKIPPED', 'Sentry is not set up yet.'));
|
|
133
|
+
if (config.length || !buildOk) {
|
|
134
|
+
for (const key of RUN)
|
|
135
|
+
results.set(key, check(key, 'SKIPPED', 'Fix the config and the build first.'));
|
|
136
|
+
return report();
|
|
137
|
+
}
|
|
138
|
+
const { saveSlot, birthDate, acceptedTerms, ...sdk } = options;
|
|
139
|
+
let krtu = null;
|
|
140
|
+
try {
|
|
141
|
+
krtu = await Krtuworks.initialize({ ...sdk, clientKind, environment: 'TEST', sessions: false });
|
|
142
|
+
const k = krtu;
|
|
143
|
+
try {
|
|
144
|
+
if (!k.player)
|
|
145
|
+
await k.signInAsGuest({ birthDate: birthDate ?? '2000-01-01', acceptedTerms: acceptedTerms ?? CURRENT_TERMS });
|
|
146
|
+
await k.ready();
|
|
147
|
+
results.set('sign_in', check('sign_in', 'PASSED'));
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
results.set('sign_in', check('sign_in', 'FAILED', why(err)));
|
|
151
|
+
for (const key of RUN.slice(1))
|
|
152
|
+
results.set(key, check(key, 'SKIPPED', 'No TEST player could sign in.'));
|
|
153
|
+
return report();
|
|
154
|
+
}
|
|
155
|
+
const step = async (key, body) => {
|
|
156
|
+
try {
|
|
157
|
+
results.set(key, (await body()) ?? check(key, 'PASSED'));
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
results.set(key, check(key, 'FAILED', why(err)));
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
// The modules the owner left on in TEST; the read that counts for the report comes last, once the build is known there.
|
|
164
|
+
await k.refreshConfig().catch(() => undefined);
|
|
165
|
+
const modules = k.modules();
|
|
166
|
+
await step('session', async () => {
|
|
167
|
+
if (!modules.sessions)
|
|
168
|
+
return check('session', 'SKIPPED', 'The sessions module is off in TEST.');
|
|
169
|
+
await k.sessionRound();
|
|
170
|
+
return null;
|
|
171
|
+
});
|
|
172
|
+
await step('events', async () => {
|
|
173
|
+
const probe = PROBES.find((p) => modules[p.module]);
|
|
174
|
+
if (!probe)
|
|
175
|
+
return check('events', 'SKIPPED', 'Every telemetry module the validator can use is off in TEST.');
|
|
176
|
+
if (!k.track(probe.type, probe.data))
|
|
177
|
+
return check('events', 'FAILED', 'The event was not kept: statistics are off for this player, or the owner switched this build off.');
|
|
178
|
+
const { events: sent, left } = await sendAll(k);
|
|
179
|
+
if (sent.rejected.length)
|
|
180
|
+
return check('events', 'FAILED', sent.rejected.map((r) => r.code).join(', '));
|
|
181
|
+
const not = sentOrWhy(sent.sent, left.events, sent.stoppedBy);
|
|
182
|
+
return not ? check('events', 'FAILED', not) : null;
|
|
183
|
+
});
|
|
184
|
+
await step('cloud_save', async () => {
|
|
185
|
+
if (!modules.cloudSave)
|
|
186
|
+
return check('cloud_save', 'SKIPPED', 'The cloudSave module is off in TEST.');
|
|
187
|
+
const { slots } = await k.files.list();
|
|
188
|
+
const usable = slots.filter((s) => !s.retired && s.kind !== 'DEVICE_SETTINGS');
|
|
189
|
+
const slot = saveSlot ? usable.find((s) => s.key === saveSlot) : usable[0];
|
|
190
|
+
if (!slot)
|
|
191
|
+
return saveSlot ? check('cloud_save', 'FAILED', `The app has no slot ${saveSlot} in TEST.`) : check('cloud_save', 'SKIPPED', 'The app has no save slot in TEST.');
|
|
192
|
+
const bytes = new TextEncoder().encode(`krtuworks-validator ${new Date().toISOString()}`);
|
|
193
|
+
const conditional = slot.kind === 'SAVE' || slot.kind === 'PROFILE';
|
|
194
|
+
try {
|
|
195
|
+
await k.files.save(slot.key, bytes, conditional ? { base: slot.file?.id ?? null } : {});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
// A slot takes one write per 10 seconds: a run right after another waits and sends the same save again.
|
|
199
|
+
const wait = isKrtuworksError(err, 'too_many_writes') ? (err.retryAfter ?? WRITE_LIMIT_S) : Infinity;
|
|
200
|
+
if (wait > WRITE_WAIT_S)
|
|
201
|
+
throw err;
|
|
202
|
+
await new Promise((r) => setTimeout(r, wait * 1000 + 250));
|
|
203
|
+
const resent = (await k.files.resume()).filter((u) => u.slot === slot.key);
|
|
204
|
+
const refused = resent.find((u) => u.outcome !== 'saved');
|
|
205
|
+
if (refused || !resent.length)
|
|
206
|
+
return check('cloud_save', 'FAILED', refused?.code ?? 'not sent');
|
|
207
|
+
}
|
|
208
|
+
const back = await k.files.read(slot.key);
|
|
209
|
+
const same = !!back && back.bytes.length === bytes.length && back.bytes.every((b, i) => b === bytes[i]);
|
|
210
|
+
return same ? null : check('cloud_save', 'FAILED', `The save read back from ${slot.key} is not the one written.`);
|
|
211
|
+
});
|
|
212
|
+
await step('progress', async () => {
|
|
213
|
+
if (!modules.achievements)
|
|
214
|
+
return check('progress', 'SKIPPED', 'The achievements module is off in TEST.');
|
|
215
|
+
const achievement = (await k.getProgress()).achievements.find((a) => !a.retired);
|
|
216
|
+
if (!achievement)
|
|
217
|
+
return check('progress', 'SKIPPED', 'The app has no achievement in TEST.');
|
|
218
|
+
const before = k.progressRefusals.length;
|
|
219
|
+
await k.unlockAchievement(achievement.key);
|
|
220
|
+
const { progress: sent, left } = await sendAll(k);
|
|
221
|
+
const refused = k.progressRefusals.slice(before).find((r) => r.key === achievement.key);
|
|
222
|
+
if (refused)
|
|
223
|
+
return check('progress', 'FAILED', refused.code);
|
|
224
|
+
const not = sentOrWhy(sent.applied, left.progress, sent.stoppedBy);
|
|
225
|
+
return not ? check('progress', 'FAILED', not) : null;
|
|
226
|
+
});
|
|
227
|
+
await step('bug_report', async () => {
|
|
228
|
+
if (!modules.bugReports)
|
|
229
|
+
return check('bug_report', 'SKIPPED', 'The bugReports module is off in TEST.');
|
|
230
|
+
const { reportId } = await k.reportBug({ category: 'OTHER', description: 'A test report from the Krtuworks validator.' });
|
|
231
|
+
const { bugs: sent, left } = await sendAll(k);
|
|
232
|
+
const dropped = sent.dropped.find((d) => d.reportId === reportId);
|
|
233
|
+
if (dropped)
|
|
234
|
+
return check('bug_report', 'FAILED', dropped.code);
|
|
235
|
+
const not = sentOrWhy(sent.sent, left.bugReports, sent.stoppedBy);
|
|
236
|
+
return not ? check('bug_report', 'FAILED', not) : null;
|
|
237
|
+
});
|
|
238
|
+
await step('remote_config', async () => {
|
|
239
|
+
await k.refreshConfig();
|
|
240
|
+
return null;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
for (const key of RUN)
|
|
245
|
+
if (!results.has(key))
|
|
246
|
+
results.set(key, check(key, 'FAILED', why(err)));
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
await krtu?.shutdown().catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
return report();
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krtuworks/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Krtuworks SDK for web games and apps: Krtuworks ID sign-in, guests, sessions, events, progress, cloud files and remote config.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "AGENTS.md", "CHANGELOG.md", "LICENSE", "NOTICE"],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "node --import tsx --test 'test/**/*.test.ts'"
|
|
20
|
+
},
|
|
21
|
+
"author": "Krtuworks LLC",
|
|
22
|
+
"license": "Apache-2.0",
|
|
23
|
+
"homepage": "https://partner.krtuworks.com/docs",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/krtuworks/krtuworks-sdk-js.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"email": "support@krtuworks.com"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^24.12.4",
|
|
39
|
+
"tsx": "^4.22.4",
|
|
40
|
+
"typescript": "^6.0.3"
|
|
41
|
+
}
|
|
42
|
+
}
|