@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/presence.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich presence states the owner declared and whether the player shows their presence (the server
|
|
3
|
+
* `/v1/me/presence-states`, `/v1/me/presence-visibility`), kept per player in the store and revalidated
|
|
4
|
+
* with the ETag. A browser has no platform presence to write, so a call that passes every rule ends in
|
|
5
|
+
* `no_platform`.
|
|
6
|
+
*/
|
|
7
|
+
export class Presence {
|
|
8
|
+
clientId;
|
|
9
|
+
store;
|
|
10
|
+
api;
|
|
11
|
+
subject;
|
|
12
|
+
cached = null;
|
|
13
|
+
constructor(clientId, store, api, subject) {
|
|
14
|
+
this.clientId = clientId;
|
|
15
|
+
this.store = store;
|
|
16
|
+
this.api = api;
|
|
17
|
+
this.subject = subject;
|
|
18
|
+
}
|
|
19
|
+
key(subject) {
|
|
20
|
+
return `krtu:${this.clientId}:presence:${subject}`;
|
|
21
|
+
}
|
|
22
|
+
/** Reads the current player's kept copy. */
|
|
23
|
+
async load() {
|
|
24
|
+
const subject = this.subject();
|
|
25
|
+
this.cached = subject ? { subject, value: (await this.store.get(this.key(subject))) ?? { etag: null, states: null, visible: null } } : null;
|
|
26
|
+
}
|
|
27
|
+
/** The player changed or signed out: nothing of the old player stays in memory. */
|
|
28
|
+
forget() {
|
|
29
|
+
this.cached = null;
|
|
30
|
+
}
|
|
31
|
+
current() {
|
|
32
|
+
return this.cached && this.cached.subject === this.subject() ? this.cached.value : null;
|
|
33
|
+
}
|
|
34
|
+
states() {
|
|
35
|
+
return this.current()?.states ?? null;
|
|
36
|
+
}
|
|
37
|
+
visible() {
|
|
38
|
+
return this.current()?.visible ?? null;
|
|
39
|
+
}
|
|
40
|
+
async keep(subject, value) {
|
|
41
|
+
this.cached = { subject, value };
|
|
42
|
+
await this.store.set(this.key(subject), value);
|
|
43
|
+
}
|
|
44
|
+
/** Asks the server for the states and the choice (If-None-Match: a 304 keeps the kept copy). */
|
|
45
|
+
async refresh() {
|
|
46
|
+
const subject = this.subject();
|
|
47
|
+
if (!subject)
|
|
48
|
+
return;
|
|
49
|
+
if (this.cached?.subject !== subject)
|
|
50
|
+
await this.load();
|
|
51
|
+
const held = this.current();
|
|
52
|
+
const answer = await this.api.player('/v1/me/presence-states', {
|
|
53
|
+
headers: held?.etag ? { 'if-none-match': held.etag } : {},
|
|
54
|
+
accept: [304],
|
|
55
|
+
});
|
|
56
|
+
if (answer.status === 304 || !held)
|
|
57
|
+
return;
|
|
58
|
+
await this.keep(subject, {
|
|
59
|
+
etag: answer.headers.get('etag'),
|
|
60
|
+
states: answer.json?.states ?? [],
|
|
61
|
+
visible: answer.json?.visible === true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async setVisible(visible) {
|
|
65
|
+
const subject = this.subject();
|
|
66
|
+
const answer = await this.api.player('/v1/me/presence-visibility', { method: 'PUT', json: { visible } });
|
|
67
|
+
if (!subject)
|
|
68
|
+
return;
|
|
69
|
+
if (this.cached?.subject !== subject)
|
|
70
|
+
await this.load();
|
|
71
|
+
await this.keep(subject, { ...(this.current() ?? { etag: null, states: null }), visible: answer.json?.visible === true });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The first rule a call breaks, or null when it passes them all: the state is declared and every
|
|
75
|
+
* parameter it declares has a non-empty value (the caller's, else the built-in one). Undeclared extras
|
|
76
|
+
* are dropped with a log line.
|
|
77
|
+
*/
|
|
78
|
+
check(key, given, builtins, log) {
|
|
79
|
+
if (this.visible() === false)
|
|
80
|
+
return 'hidden';
|
|
81
|
+
const states = this.states();
|
|
82
|
+
if (!states)
|
|
83
|
+
return 'states_unknown';
|
|
84
|
+
const state = states.find((s) => s.key === key);
|
|
85
|
+
if (!state)
|
|
86
|
+
return 'unknown_state';
|
|
87
|
+
const extra = Object.keys(given).filter((name) => !state.params.includes(name));
|
|
88
|
+
if (extra.length)
|
|
89
|
+
log(`presence ${key}: dropped undeclared params ${extra.join(', ')}`);
|
|
90
|
+
for (const name of state.params) {
|
|
91
|
+
const value = Object.prototype.hasOwnProperty.call(given, name) ? String(given[name]) : (builtins[name]?.() ?? '');
|
|
92
|
+
if (value === '')
|
|
93
|
+
return 'param_missing';
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Api } from './api.js';
|
|
2
|
+
import type { ResolvedOptions } from './config.js';
|
|
3
|
+
import type { KrtuStore } from './store.js';
|
|
4
|
+
export type StatKind = 'SUM' | 'MAX' | 'MIN' | 'LATEST';
|
|
5
|
+
export type SortOrder = 'ASC' | 'DESC';
|
|
6
|
+
export type UploadMethod = 'KEEP_BEST' | 'FORCE_UPDATE';
|
|
7
|
+
/** What folding a leaderboard's waiting scores needs: which score is better and what a new one does to the stored one. */
|
|
8
|
+
export interface BoardRule {
|
|
9
|
+
sortOrder: SortOrder;
|
|
10
|
+
uploadMethod: UploadMethod;
|
|
11
|
+
}
|
|
12
|
+
/** A value the game reported and not yet frozen into an op: an increase, or a value to keep. */
|
|
13
|
+
interface Contribution {
|
|
14
|
+
op: 'add' | 'set';
|
|
15
|
+
value: number;
|
|
16
|
+
}
|
|
17
|
+
/** A score the game submitted and not yet frozen into an op. */
|
|
18
|
+
interface ScoreContribution {
|
|
19
|
+
value: number;
|
|
20
|
+
details?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ProgressRefusal {
|
|
23
|
+
type: 'stat' | 'unlock' | 'score';
|
|
24
|
+
key: string;
|
|
25
|
+
code: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Folds what waits for one stat into as few values as its kind allows: SUM adds, MAX
|
|
29
|
+
* keeps the largest, MIN the smallest, LATEST the last. A stat whose kind is not known yet only folds
|
|
30
|
+
* its runs of increases; values to keep wait one by one, in order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function foldContributions(pending: Contribution[], kind: StatKind | undefined): Contribution[];
|
|
33
|
+
/**
|
|
34
|
+
* Folds what waits for one leaderboard into as few scores as its upload method allows: KEEP_BEST keeps
|
|
35
|
+
* the better one (an equal score keeps the earlier, as the server keeps the stored one), FORCE_UPDATE
|
|
36
|
+
* the last. A board whose rule is not known yet folds nothing: every score waits, in order.
|
|
37
|
+
*/
|
|
38
|
+
export declare function foldScores(pending: ScoreContribution[], rule: BoardRule | undefined): ScoreContribution[];
|
|
39
|
+
export interface ProgressContext {
|
|
40
|
+
opts: ResolvedOptions;
|
|
41
|
+
store: KrtuStore;
|
|
42
|
+
api: Api;
|
|
43
|
+
subject: () => string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Stats and achievements: progress is never lost and never
|
|
47
|
+
* counted twice. Every report is written to the store at once; per stat at most one op is in flight,
|
|
48
|
+
* frozen with its opId until it has an answer (sent again unchanged after a failure), and the rest
|
|
49
|
+
* waits folded behind it. Ops have no age limit and do not depend on the statistics choice.
|
|
50
|
+
*/
|
|
51
|
+
export declare class ProgressQueue {
|
|
52
|
+
private readonly ctx;
|
|
53
|
+
private kinds;
|
|
54
|
+
private kindsLoaded;
|
|
55
|
+
private boards;
|
|
56
|
+
private notBefore;
|
|
57
|
+
private backoffMs;
|
|
58
|
+
readonly refusals: ProgressRefusal[];
|
|
59
|
+
constructor(ctx: ProgressContext);
|
|
60
|
+
private key;
|
|
61
|
+
private get kindsKey();
|
|
62
|
+
private get boardsKey();
|
|
63
|
+
private load;
|
|
64
|
+
private save;
|
|
65
|
+
/** Loads what the server taught: the stats' kinds and the leaderboards' rules. */
|
|
66
|
+
private loadKinds;
|
|
67
|
+
/** Learns the stats' kinds (from `GET /v1/me/progress`): what waits folds tighter from now on. */
|
|
68
|
+
learnKinds(stats: Array<{
|
|
69
|
+
key: string;
|
|
70
|
+
kind: StatKind;
|
|
71
|
+
}>): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Learns the leaderboards' rules (from `GET /v1/me/leaderboards`): what waits folds tighter from now
|
|
74
|
+
* on. A board the server answers without its upload method stays unknown, so nothing folds for it.
|
|
75
|
+
*/
|
|
76
|
+
learnBoards(boards: Array<{
|
|
77
|
+
key: string;
|
|
78
|
+
sortOrder: SortOrder;
|
|
79
|
+
uploadMethod?: UploadMethod;
|
|
80
|
+
}>): Promise<void>;
|
|
81
|
+
report(type: 'add' | 'set', key: string, value: number): Promise<void>;
|
|
82
|
+
submitScore(key: string, value: number, details?: Uint8Array): Promise<void>;
|
|
83
|
+
unlock(key: string): Promise<void>;
|
|
84
|
+
/** How many ops wait for `owner` (in flight or not yet frozen). */
|
|
85
|
+
waiting(owner: string): Promise<number>;
|
|
86
|
+
/**
|
|
87
|
+
* Moves `from`'s waiting progress to `to` (the first player after anon play, or a merge target).
|
|
88
|
+
* Frozen ops keep their ids: one the server already applied under the old account answers
|
|
89
|
+
* `duplicate` under the new one (the merge moved its receipt), so nothing counts twice.
|
|
90
|
+
*/
|
|
91
|
+
move(from: string, to: string): Promise<void>;
|
|
92
|
+
/** Freezes the next op of every stat and achievement with none in flight; returns every op to send. */
|
|
93
|
+
private freeze;
|
|
94
|
+
/**
|
|
95
|
+
* Sends the signed-in player's ops until nothing waits or a send fails. Answers per op: applied and
|
|
96
|
+
* duplicate end the op; a refusal for good ends it too (and everything waiting for a stat,
|
|
97
|
+
* achievement or leaderboard that no longer exists); anything else keeps it frozen for the next flush.
|
|
98
|
+
* A score refused `module_disabled` stays frozen too: the owner may switch the module on again.
|
|
99
|
+
*/
|
|
100
|
+
flush(): Promise<{
|
|
101
|
+
applied: number;
|
|
102
|
+
stoppedBy: string | null;
|
|
103
|
+
values: Record<string, number>;
|
|
104
|
+
}>;
|
|
105
|
+
private backoff;
|
|
106
|
+
resetBackoff(): void;
|
|
107
|
+
}
|
|
108
|
+
export {};
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { KrtuworksError, isKrtuworksError } from './errors.js';
|
|
2
|
+
import { base64, uuidV7 } from './ids.js';
|
|
3
|
+
import { withLock } from './locks.js';
|
|
4
|
+
const KEY = /^[a-z][a-z0-9_.]{0,63}$/;
|
|
5
|
+
const MAX_VALUE = 1e15;
|
|
6
|
+
const MAX_OPS = 100;
|
|
7
|
+
/** Refusals that say the stat or achievement itself is gone: what waits for it goes too. */
|
|
8
|
+
const KEY_GONE = new Set(['unknown_stat', 'stat_retired', 'unknown_achievement', 'achievement_retired']);
|
|
9
|
+
/** Refusals that say the leaderboard itself is gone: what waits for it goes too. */
|
|
10
|
+
const BOARD_GONE = new Set(['unknown_leaderboard', 'leaderboard_retired']);
|
|
11
|
+
const MAX_DETAILS = 64;
|
|
12
|
+
const empty = () => ({ stats: {}, unlocks: {}, scores: {} });
|
|
13
|
+
/**
|
|
14
|
+
* Folds what waits for one stat into as few values as its kind allows: SUM adds, MAX
|
|
15
|
+
* keeps the largest, MIN the smallest, LATEST the last. A stat whose kind is not known yet only folds
|
|
16
|
+
* its runs of increases; values to keep wait one by one, in order.
|
|
17
|
+
*/
|
|
18
|
+
export function foldContributions(pending, kind) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const c of pending) {
|
|
21
|
+
const last = out[out.length - 1];
|
|
22
|
+
if (!last) {
|
|
23
|
+
out.push({ ...c });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (kind === 'SUM' || (kind === undefined && c.op === 'add' && last.op === 'add')) {
|
|
27
|
+
const sum = last.value + c.value;
|
|
28
|
+
if (Math.abs(sum) < MAX_VALUE) {
|
|
29
|
+
last.value = sum;
|
|
30
|
+
last.op = 'add';
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (kind === 'MAX') {
|
|
35
|
+
last.value = Math.max(last.value, c.value);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
else if (kind === 'MIN') {
|
|
39
|
+
last.value = Math.min(last.value, c.value);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
else if (kind === 'LATEST') {
|
|
43
|
+
last.value = c.value;
|
|
44
|
+
last.op = c.op;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push({ ...c });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Folds what waits for one leaderboard into as few scores as its upload method allows: KEEP_BEST keeps
|
|
53
|
+
* the better one (an equal score keeps the earlier, as the server keeps the stored one), FORCE_UPDATE
|
|
54
|
+
* the last. A board whose rule is not known yet folds nothing: every score waits, in order.
|
|
55
|
+
*/
|
|
56
|
+
export function foldScores(pending, rule) {
|
|
57
|
+
if (!rule)
|
|
58
|
+
return pending.map((c) => ({ ...c }));
|
|
59
|
+
if (rule.uploadMethod === 'FORCE_UPDATE')
|
|
60
|
+
return pending.length ? [{ ...pending[pending.length - 1] }] : [];
|
|
61
|
+
let best;
|
|
62
|
+
for (const c of pending) {
|
|
63
|
+
if (!best || (rule.sortOrder === 'DESC' ? c.value > best.value : c.value < best.value))
|
|
64
|
+
best = c;
|
|
65
|
+
}
|
|
66
|
+
return best ? [{ ...best }] : [];
|
|
67
|
+
}
|
|
68
|
+
function findInFlight(state, opId) {
|
|
69
|
+
const groups = [['stat', state.stats], ['unlock', state.unlocks], ['score', state.scores]];
|
|
70
|
+
for (const [type, entries] of groups) {
|
|
71
|
+
for (const [key, entry] of Object.entries(entries))
|
|
72
|
+
if (entry.inFlight.some((op) => op.opId === opId))
|
|
73
|
+
return { type, key, entry };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Stats and achievements: progress is never lost and never
|
|
79
|
+
* counted twice. Every report is written to the store at once; per stat at most one op is in flight,
|
|
80
|
+
* frozen with its opId until it has an answer (sent again unchanged after a failure), and the rest
|
|
81
|
+
* waits folded behind it. Ops have no age limit and do not depend on the statistics choice.
|
|
82
|
+
*/
|
|
83
|
+
export class ProgressQueue {
|
|
84
|
+
ctx;
|
|
85
|
+
kinds = {};
|
|
86
|
+
kindsLoaded = false;
|
|
87
|
+
boards = {};
|
|
88
|
+
notBefore = 0;
|
|
89
|
+
backoffMs = 0;
|
|
90
|
+
refusals = [];
|
|
91
|
+
constructor(ctx) {
|
|
92
|
+
this.ctx = ctx;
|
|
93
|
+
}
|
|
94
|
+
key(owner) {
|
|
95
|
+
return `krtu:${this.ctx.opts.clientId}:progress:${owner}`;
|
|
96
|
+
}
|
|
97
|
+
get kindsKey() {
|
|
98
|
+
return `krtu:${this.ctx.opts.clientId}:statKinds`;
|
|
99
|
+
}
|
|
100
|
+
get boardsKey() {
|
|
101
|
+
return `krtu:${this.ctx.opts.clientId}:boardRules`;
|
|
102
|
+
}
|
|
103
|
+
async load(owner) {
|
|
104
|
+
const state = (await this.ctx.store.get(this.key(owner))) ?? empty();
|
|
105
|
+
state.scores ??= {};
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
async save(owner, state) {
|
|
109
|
+
for (const [key, s] of Object.entries(state.stats))
|
|
110
|
+
if (!s.inFlight.length && !s.pending.length)
|
|
111
|
+
delete state.stats[key];
|
|
112
|
+
for (const [key, u] of Object.entries(state.unlocks))
|
|
113
|
+
if (!u.inFlight.length && !u.pending)
|
|
114
|
+
delete state.unlocks[key];
|
|
115
|
+
for (const [key, b] of Object.entries(state.scores))
|
|
116
|
+
if (!b.inFlight.length && !b.pending.length)
|
|
117
|
+
delete state.scores[key];
|
|
118
|
+
if (Object.keys(state.stats).length || Object.keys(state.unlocks).length || Object.keys(state.scores).length)
|
|
119
|
+
await this.ctx.store.set(this.key(owner), state);
|
|
120
|
+
else
|
|
121
|
+
await this.ctx.store.delete(this.key(owner));
|
|
122
|
+
}
|
|
123
|
+
/** Loads what the server taught: the stats' kinds and the leaderboards' rules. */
|
|
124
|
+
async loadKinds() {
|
|
125
|
+
if (this.kindsLoaded)
|
|
126
|
+
return;
|
|
127
|
+
this.kinds = (await this.ctx.store.get(this.kindsKey)) ?? {};
|
|
128
|
+
this.boards = (await this.ctx.store.get(this.boardsKey)) ?? {};
|
|
129
|
+
this.kindsLoaded = true;
|
|
130
|
+
}
|
|
131
|
+
/** Learns the stats' kinds (from `GET /v1/me/progress`): what waits folds tighter from now on. */
|
|
132
|
+
async learnKinds(stats) {
|
|
133
|
+
await this.loadKinds();
|
|
134
|
+
for (const s of stats)
|
|
135
|
+
this.kinds[s.key] = s.kind;
|
|
136
|
+
await this.ctx.store.set(this.kindsKey, this.kinds);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Learns the leaderboards' rules (from `GET /v1/me/leaderboards`): what waits folds tighter from now
|
|
140
|
+
* on. A board the server answers without its upload method stays unknown, so nothing folds for it.
|
|
141
|
+
*/
|
|
142
|
+
async learnBoards(boards) {
|
|
143
|
+
await this.loadKinds();
|
|
144
|
+
for (const b of boards)
|
|
145
|
+
if (b.uploadMethod)
|
|
146
|
+
this.boards[b.key] = { sortOrder: b.sortOrder, uploadMethod: b.uploadMethod };
|
|
147
|
+
await this.ctx.store.set(this.boardsKey, this.boards);
|
|
148
|
+
}
|
|
149
|
+
async report(type, key, value) {
|
|
150
|
+
if (!KEY.test(key))
|
|
151
|
+
throw new TypeError(`"${key}" is not a stat key (lowercase letters, digits, _ and ., at most 64)`);
|
|
152
|
+
if (!Number.isFinite(value) || Math.abs(value) >= MAX_VALUE)
|
|
153
|
+
throw new TypeError('a stat value is a finite number below 10^15');
|
|
154
|
+
await this.loadKinds();
|
|
155
|
+
const owner = this.ctx.subject() ?? 'anon';
|
|
156
|
+
await withLock(this.key(owner), async () => {
|
|
157
|
+
const state = await this.load(owner);
|
|
158
|
+
const entry = (state.stats[key] ??= { inFlight: [], pending: [] });
|
|
159
|
+
entry.pending = foldContributions([...entry.pending, { op: type, value }], this.kinds[key]);
|
|
160
|
+
await this.save(owner, state);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async submitScore(key, value, details) {
|
|
164
|
+
if (!KEY.test(key))
|
|
165
|
+
throw new TypeError(`"${key}" is not a leaderboard key (lowercase letters, digits, _ and ., at most 64)`);
|
|
166
|
+
if (!Number.isInteger(value) || Math.abs(value) >= MAX_VALUE)
|
|
167
|
+
throw new TypeError('a score is a whole number below 10^15');
|
|
168
|
+
if (details !== undefined && (!(details instanceof Uint8Array) || details.length < 1 || details.length > MAX_DETAILS)) {
|
|
169
|
+
throw new TypeError(`score details are 1-${MAX_DETAILS} bytes`);
|
|
170
|
+
}
|
|
171
|
+
await this.loadKinds();
|
|
172
|
+
const owner = this.ctx.subject() ?? 'anon';
|
|
173
|
+
await withLock(this.key(owner), async () => {
|
|
174
|
+
const state = await this.load(owner);
|
|
175
|
+
const entry = (state.scores[key] ??= { inFlight: [], pending: [] });
|
|
176
|
+
entry.pending = foldScores([...entry.pending, { value, ...(details ? { details: base64(details) } : {}) }], this.boards[key]);
|
|
177
|
+
await this.save(owner, state);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async unlock(key) {
|
|
181
|
+
if (!KEY.test(key))
|
|
182
|
+
throw new TypeError(`"${key}" is not an achievement key`);
|
|
183
|
+
const owner = this.ctx.subject() ?? 'anon';
|
|
184
|
+
await withLock(this.key(owner), async () => {
|
|
185
|
+
const state = await this.load(owner);
|
|
186
|
+
const entry = (state.unlocks[key] ??= { inFlight: [], pending: false });
|
|
187
|
+
if (!entry.inFlight.length)
|
|
188
|
+
entry.pending = true;
|
|
189
|
+
await this.save(owner, state);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** How many ops wait for `owner` (in flight or not yet frozen). */
|
|
193
|
+
async waiting(owner) {
|
|
194
|
+
const state = await this.load(owner);
|
|
195
|
+
return (Object.values(state.stats).reduce((n, s) => n + s.inFlight.length + s.pending.length, 0) +
|
|
196
|
+
Object.values(state.unlocks).reduce((n, u) => n + u.inFlight.length + (u.pending ? 1 : 0), 0) +
|
|
197
|
+
Object.values(state.scores).reduce((n, b) => n + b.inFlight.length + b.pending.length, 0));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Moves `from`'s waiting progress to `to` (the first player after anon play, or a merge target).
|
|
201
|
+
* Frozen ops keep their ids: one the server already applied under the old account answers
|
|
202
|
+
* `duplicate` under the new one (the merge moved its receipt), so nothing counts twice.
|
|
203
|
+
*/
|
|
204
|
+
async move(from, to) {
|
|
205
|
+
await this.loadKinds();
|
|
206
|
+
const [first, second] = from < to ? [from, to] : [to, from];
|
|
207
|
+
await withLock(this.key(first), () => withLock(this.key(second), async () => {
|
|
208
|
+
const source = await this.load(from);
|
|
209
|
+
const target = await this.load(to);
|
|
210
|
+
for (const [key, s] of Object.entries(source.stats)) {
|
|
211
|
+
const t = (target.stats[key] ??= { inFlight: [], pending: [] });
|
|
212
|
+
t.inFlight.push(...s.inFlight);
|
|
213
|
+
t.pending = foldContributions([...s.pending, ...t.pending], this.kinds[key]);
|
|
214
|
+
}
|
|
215
|
+
for (const [key, u] of Object.entries(source.unlocks)) {
|
|
216
|
+
const t = (target.unlocks[key] ??= { inFlight: [], pending: false });
|
|
217
|
+
t.inFlight.push(...u.inFlight);
|
|
218
|
+
t.pending = t.pending || (u.pending && !t.inFlight.length);
|
|
219
|
+
}
|
|
220
|
+
for (const [key, b] of Object.entries(source.scores)) {
|
|
221
|
+
const t = (target.scores[key] ??= { inFlight: [], pending: [] });
|
|
222
|
+
t.inFlight.push(...b.inFlight);
|
|
223
|
+
t.pending = foldScores([...b.pending, ...t.pending], this.boards[key]);
|
|
224
|
+
}
|
|
225
|
+
await this.save(to, target);
|
|
226
|
+
await this.ctx.store.delete(this.key(from));
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
/** Freezes the next op of every stat and achievement with none in flight; returns every op to send. */
|
|
230
|
+
freeze(state) {
|
|
231
|
+
const ops = [];
|
|
232
|
+
for (const [key, s] of Object.entries(state.stats)) {
|
|
233
|
+
if (!s.inFlight.length && s.pending.length) {
|
|
234
|
+
s.pending = foldContributions(s.pending, this.kinds[key]);
|
|
235
|
+
const next = s.pending.shift();
|
|
236
|
+
s.inFlight.push({ opId: uuidV7(), type: 'stat', key, value: next.value });
|
|
237
|
+
}
|
|
238
|
+
ops.push(...s.inFlight);
|
|
239
|
+
}
|
|
240
|
+
for (const [key, u] of Object.entries(state.unlocks)) {
|
|
241
|
+
if (!u.inFlight.length && u.pending) {
|
|
242
|
+
u.inFlight.push({ opId: uuidV7(), type: 'unlock', key });
|
|
243
|
+
u.pending = false;
|
|
244
|
+
}
|
|
245
|
+
ops.push(...u.inFlight);
|
|
246
|
+
}
|
|
247
|
+
for (const [key, b] of Object.entries(state.scores)) {
|
|
248
|
+
if (!b.inFlight.length && b.pending.length) {
|
|
249
|
+
b.pending = foldScores(b.pending, this.boards[key]);
|
|
250
|
+
const next = b.pending.shift();
|
|
251
|
+
b.inFlight.push({ opId: uuidV7(), type: 'score', key, value: next.value, ...(next.details ? { details: next.details } : {}) });
|
|
252
|
+
}
|
|
253
|
+
ops.push(...b.inFlight);
|
|
254
|
+
}
|
|
255
|
+
return ops.slice(0, MAX_OPS);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Sends the signed-in player's ops until nothing waits or a send fails. Answers per op: applied and
|
|
259
|
+
* duplicate end the op; a refusal for good ends it too (and everything waiting for a stat,
|
|
260
|
+
* achievement or leaderboard that no longer exists); anything else keeps it frozen for the next flush.
|
|
261
|
+
* A score refused `module_disabled` stays frozen too: the owner may switch the module on again.
|
|
262
|
+
*/
|
|
263
|
+
async flush() {
|
|
264
|
+
const owner = this.ctx.subject();
|
|
265
|
+
const out = { applied: 0, stoppedBy: null, values: {} };
|
|
266
|
+
if (!owner)
|
|
267
|
+
return { ...out, stoppedBy: 'signed_out' };
|
|
268
|
+
if (Date.now() < this.notBefore)
|
|
269
|
+
return { ...out, stoppedBy: 'waiting' };
|
|
270
|
+
await this.loadKinds();
|
|
271
|
+
for (let round = 0; round < 50; round += 1) {
|
|
272
|
+
const ops = await withLock(this.key(owner), async () => {
|
|
273
|
+
const state = await this.load(owner);
|
|
274
|
+
const frozen = this.freeze(state);
|
|
275
|
+
await this.save(owner, state);
|
|
276
|
+
return frozen;
|
|
277
|
+
});
|
|
278
|
+
if (!ops.length)
|
|
279
|
+
return out;
|
|
280
|
+
let answer;
|
|
281
|
+
try {
|
|
282
|
+
answer = await this.ctx.api.player('/v1/me/progress', {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
json: { platform: this.ctx.opts.platform, build: this.ctx.opts.build, ops },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (!isKrtuworksError(err))
|
|
289
|
+
throw err;
|
|
290
|
+
this.backoff(err);
|
|
291
|
+
out.stoppedBy = err.code;
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
this.backoffMs = 0;
|
|
295
|
+
const results = answer.json?.results ?? [];
|
|
296
|
+
const settled = await withLock(this.key(owner), async () => {
|
|
297
|
+
const state = await this.load(owner);
|
|
298
|
+
let count = 0;
|
|
299
|
+
for (const r of results) {
|
|
300
|
+
// A refusal carries the key but not the type, and op_id_reused neither: the op is found by its id.
|
|
301
|
+
const hit = findInFlight(state, r.opId);
|
|
302
|
+
if (!hit)
|
|
303
|
+
continue;
|
|
304
|
+
const { type, key, entry } = hit;
|
|
305
|
+
if (r.status === 'rejected' && type === 'score' && r.code === 'module_disabled') {
|
|
306
|
+
out.stoppedBy = 'module_disabled';
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
entry.inFlight = entry.inFlight.filter((op) => op.opId !== r.opId);
|
|
310
|
+
count += 1;
|
|
311
|
+
if (r.status === 'rejected') {
|
|
312
|
+
this.refusals.push({ type, key, code: r.code ?? 'rejected' });
|
|
313
|
+
if (type === 'score' ? BOARD_GONE.has(r.code ?? '') : KEY_GONE.has(r.code ?? '')) {
|
|
314
|
+
if (type === 'unlock')
|
|
315
|
+
delete state.unlocks[key];
|
|
316
|
+
else if (type === 'score')
|
|
317
|
+
delete state.scores[key];
|
|
318
|
+
else
|
|
319
|
+
delete state.stats[key];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
out.applied += 1;
|
|
324
|
+
if (type === 'stat' && typeof r.value === 'number')
|
|
325
|
+
out.values[key] = r.value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await this.save(owner, state);
|
|
329
|
+
return count;
|
|
330
|
+
});
|
|
331
|
+
if (!settled) {
|
|
332
|
+
// An answer that settles none of the ops sent: keep them frozen and try again later.
|
|
333
|
+
out.stoppedBy ??= 'unanswered';
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
backoff(err) {
|
|
340
|
+
this.backoffMs = Math.min(300_000, Math.max(5_000, this.backoffMs * 2));
|
|
341
|
+
this.notBefore = Date.now() + (err.retryAfter !== null ? err.retryAfter * 1000 : this.backoffMs);
|
|
342
|
+
}
|
|
343
|
+
resetBackoff() {
|
|
344
|
+
this.notBefore = 0;
|
|
345
|
+
this.backoffMs = 0;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FamilyData } from './families.js';
|
|
2
|
+
import type { KrtuStore } from './store.js';
|
|
3
|
+
export interface LevelRef {
|
|
4
|
+
level: string;
|
|
5
|
+
world?: string;
|
|
6
|
+
part?: string;
|
|
7
|
+
}
|
|
8
|
+
type ProgressionEvent = FamilyData['progression'];
|
|
9
|
+
/**
|
|
10
|
+
* Levels as the progression family tells them: the game says start, complete, fail,
|
|
11
|
+
* quit, checkpoint and restart; the SDK adds the attempt number (per level and player, kept across
|
|
12
|
+
* launches), the attempt's wall and active seconds and its deaths. An attempt still open when the app
|
|
13
|
+
* goes away is told as a quit.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ProgressionTracker {
|
|
16
|
+
private readonly store;
|
|
17
|
+
private readonly clientId;
|
|
18
|
+
private readonly owner;
|
|
19
|
+
private readonly activeSeconds;
|
|
20
|
+
private readonly emit;
|
|
21
|
+
private current;
|
|
22
|
+
constructor(store: KrtuStore, clientId: string, owner: () => string, activeSeconds: () => number, emit: (data: ProgressionEvent) => void);
|
|
23
|
+
private key;
|
|
24
|
+
private ref;
|
|
25
|
+
private times;
|
|
26
|
+
private place;
|
|
27
|
+
start(level: LevelRef): Promise<void>;
|
|
28
|
+
/** Ends the attempt in play (complete, fail or quit). */
|
|
29
|
+
finish(action: 'complete' | 'fail' | 'quit', extra?: {
|
|
30
|
+
score?: number;
|
|
31
|
+
}): void;
|
|
32
|
+
checkpoint(name: string): void;
|
|
33
|
+
/** The level starts over: the attempt in play ends as a restart and a new one begins. */
|
|
34
|
+
restart(): Promise<void>;
|
|
35
|
+
/** A death counts toward the attempt in play. */
|
|
36
|
+
noteDeath(): LevelRef | null;
|
|
37
|
+
get inPlay(): LevelRef | null;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Levels as the progression family tells them: the game says start, complete, fail,
|
|
3
|
+
* quit, checkpoint and restart; the SDK adds the attempt number (per level and player, kept across
|
|
4
|
+
* launches), the attempt's wall and active seconds and its deaths. An attempt still open when the app
|
|
5
|
+
* goes away is told as a quit.
|
|
6
|
+
*/
|
|
7
|
+
export class ProgressionTracker {
|
|
8
|
+
store;
|
|
9
|
+
clientId;
|
|
10
|
+
owner;
|
|
11
|
+
activeSeconds;
|
|
12
|
+
emit;
|
|
13
|
+
current = null;
|
|
14
|
+
constructor(store, clientId, owner, activeSeconds, emit) {
|
|
15
|
+
this.store = store;
|
|
16
|
+
this.clientId = clientId;
|
|
17
|
+
this.owner = owner;
|
|
18
|
+
this.activeSeconds = activeSeconds;
|
|
19
|
+
this.emit = emit;
|
|
20
|
+
}
|
|
21
|
+
key() {
|
|
22
|
+
return `krtu:${this.clientId}:levels:${this.owner()}`;
|
|
23
|
+
}
|
|
24
|
+
ref(level) {
|
|
25
|
+
return [level.world ?? '', level.level, level.part ?? ''].join('\u0000');
|
|
26
|
+
}
|
|
27
|
+
times(a) {
|
|
28
|
+
return {
|
|
29
|
+
attempt: a.attempt,
|
|
30
|
+
seconds: Math.round((Date.now() - a.startedAt) / 100) / 10,
|
|
31
|
+
activeSeconds: Math.max(0, this.activeSeconds() - a.activeAtStart),
|
|
32
|
+
deaths: a.deaths,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
place(a) {
|
|
36
|
+
return { level: a.level, ...(a.world ? { world: a.world } : {}), ...(a.part ? { part: a.part } : {}) };
|
|
37
|
+
}
|
|
38
|
+
async start(level) {
|
|
39
|
+
if (this.current)
|
|
40
|
+
this.finish('quit');
|
|
41
|
+
const counts = (await this.store.get(this.key())) ?? {};
|
|
42
|
+
const ref = this.ref(level);
|
|
43
|
+
counts[ref] = (counts[ref] ?? 0) + 1;
|
|
44
|
+
await this.store.set(this.key(), counts);
|
|
45
|
+
this.current = { ...level, attempt: counts[ref], startedAt: Date.now(), activeAtStart: this.activeSeconds(), deaths: 0 };
|
|
46
|
+
this.emit({ action: 'start', ...this.place(level), attempt: this.current.attempt });
|
|
47
|
+
}
|
|
48
|
+
/** Ends the attempt in play (complete, fail or quit). */
|
|
49
|
+
finish(action, extra = {}) {
|
|
50
|
+
const a = this.current;
|
|
51
|
+
if (!a)
|
|
52
|
+
return;
|
|
53
|
+
this.current = null;
|
|
54
|
+
this.emit({ action, ...this.place(a), ...this.times(a), ...(extra.score !== undefined ? { score: extra.score } : {}) });
|
|
55
|
+
}
|
|
56
|
+
checkpoint(name) {
|
|
57
|
+
const a = this.current;
|
|
58
|
+
if (!a)
|
|
59
|
+
return;
|
|
60
|
+
this.emit({ action: 'checkpoint', ...this.place(a), checkpoint: name, ...this.times(a) });
|
|
61
|
+
}
|
|
62
|
+
/** The level starts over: the attempt in play ends as a restart and a new one begins. */
|
|
63
|
+
async restart() {
|
|
64
|
+
const a = this.current;
|
|
65
|
+
if (!a)
|
|
66
|
+
return;
|
|
67
|
+
this.current = null;
|
|
68
|
+
this.emit({ action: 'restart', ...this.place(a), ...this.times(a) });
|
|
69
|
+
await this.start(a);
|
|
70
|
+
}
|
|
71
|
+
/** A death counts toward the attempt in play. */
|
|
72
|
+
noteDeath() {
|
|
73
|
+
if (!this.current)
|
|
74
|
+
return null;
|
|
75
|
+
this.current.deaths += 1;
|
|
76
|
+
return { level: this.current.level, world: this.current.world, part: this.current.part };
|
|
77
|
+
}
|
|
78
|
+
get inPlay() {
|
|
79
|
+
return this.current ? { level: this.current.level, world: this.current.world, part: this.current.part } : null;
|
|
80
|
+
}
|
|
81
|
+
}
|