@orakl/shared 0.1.0 → 0.2.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/dist/badges.d.ts +48 -16
- package/dist/badges.js +343 -49
- package/package.json +3 -3
package/dist/badges.d.ts
CHANGED
|
@@ -32,6 +32,18 @@ export type BadgeContext = {
|
|
|
32
32
|
score: number;
|
|
33
33
|
/** Questions actually served in this game. */
|
|
34
34
|
questionsServed: number;
|
|
35
|
+
/** Rounds this player was right while most of the field was wrong (Cassandra). */
|
|
36
|
+
fieldRoundsDefied?: number;
|
|
37
|
+
/** Rounds this player alone was right with the field answering (Lone torch). */
|
|
38
|
+
soleCorrectRounds?: number;
|
|
39
|
+
/**
|
|
40
|
+
* This player's rank after each closed round, with the field size then;
|
|
41
|
+
* null for rounds before they held a hero slot (Ascendant, Undisputed).
|
|
42
|
+
*/
|
|
43
|
+
rankHistory?: Array<{
|
|
44
|
+
rank: number;
|
|
45
|
+
field: number;
|
|
46
|
+
} | null>;
|
|
35
47
|
};
|
|
36
48
|
export interface BadgeInput {
|
|
37
49
|
context: BadgeContext;
|
|
@@ -52,22 +64,30 @@ export interface BadgeInput {
|
|
|
52
64
|
/** Mean time-to-answer (ms) across the run. */
|
|
53
65
|
timeToAnswerAvgMs: number;
|
|
54
66
|
/**
|
|
55
|
-
*
|
|
56
|
-
* solvers
|
|
57
|
-
* samples to judge. Drives the Faster-than-most badge (US #11).
|
|
58
|
-
*/
|
|
59
|
-
fasterThanMaxPercent: number | null;
|
|
60
|
-
/**
|
|
61
|
-
* Total times the player switched a selection before committing, across the
|
|
62
|
-
* run. 0 means every answer was committed first-try (sure-handed). Undefined
|
|
63
|
-
* when the client didn't report changes (legacy) — the badge then can't award.
|
|
67
|
+
* Correct answers this run that each beat more than 75% of prior correct
|
|
68
|
+
* solvers on time (the player's own attempts excluded). Drives Wing-footed.
|
|
64
69
|
*/
|
|
65
|
-
|
|
70
|
+
fastAnswerCount: number;
|
|
66
71
|
/**
|
|
67
72
|
* The player's consecutive-days-played streak as of this run (signed-in only).
|
|
68
73
|
* Drives the Devotee badge; undefined for guests / when not computed.
|
|
69
74
|
*/
|
|
70
75
|
consecutiveDays?: number;
|
|
76
|
+
/** This run's total score. Drives New heights; undefined when not computed. */
|
|
77
|
+
runScore?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Best score across the player's prior completed runs in this run's exact
|
|
80
|
+
* trial bucket (question count, timer, difficulty; normal mode); null when
|
|
81
|
+
* none. Drives New heights; undefined for guests / lookup failure.
|
|
82
|
+
*/
|
|
83
|
+
bucketBest?: number | null;
|
|
84
|
+
/** Count of those prior runs; New heights needs at least 3. */
|
|
85
|
+
bucketPriorRuns?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Distinct categories the player answered correctly across prior runs.
|
|
88
|
+
* Drives Far horizons; undefined for guests / lookup failure.
|
|
89
|
+
*/
|
|
90
|
+
priorCorrectCategories?: string[];
|
|
71
91
|
results: Array<{
|
|
72
92
|
timeToAnswerMs: number;
|
|
73
93
|
categoryId: string;
|
|
@@ -76,8 +96,26 @@ export interface BadgeInput {
|
|
|
76
96
|
difficulty?: "easy" | "medium" | "hard";
|
|
77
97
|
}>;
|
|
78
98
|
}
|
|
99
|
+
/** An answer beating more than this % of prior solvers counts toward Wing-footed. */
|
|
100
|
+
export declare const FASTER_THAN_MOST_PERCENT = 75;
|
|
79
101
|
/** Served-questions floor for the rank badges (victor, podium) and lifetime best-rank — below this a rank isn't a claim. */
|
|
80
102
|
export declare const RANK_BADGE_MIN_QUESTIONS = 6;
|
|
103
|
+
/** One player's row for one round of a multiplayer game's answer log. */
|
|
104
|
+
export interface FieldLogEntry {
|
|
105
|
+
questionIndex: number;
|
|
106
|
+
isCorrect: boolean;
|
|
107
|
+
/** True when the player chose an answer; false for a miss-filled round. */
|
|
108
|
+
submitted: boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Field-relative round counts for `playerId` from every tenured player's log.
|
|
112
|
+
* The field for a round is the other players' submitted answers; a miss-filled
|
|
113
|
+
* row (a leaver, or a timeout) is not a wrong answer and is left out.
|
|
114
|
+
*/
|
|
115
|
+
export declare function fieldRoundStats(playerId: string, logs: ReadonlyMap<string, readonly FieldLogEntry[]>): {
|
|
116
|
+
fieldRoundsDefied: number;
|
|
117
|
+
soleCorrectRounds: number;
|
|
118
|
+
};
|
|
81
119
|
/**
|
|
82
120
|
* Raw share (0–1) of answers committed before the clock's halfway mark — quick
|
|
83
121
|
* answers read as decisive (implicit confidence from timing). Null when not
|
|
@@ -103,12 +141,6 @@ export declare function computeBadges(input: BadgeInput): BadgeAward[];
|
|
|
103
141
|
export type Rarity = "common" | "rare" | "legendary" | "exotic";
|
|
104
142
|
export declare const RARITY_ORDER: Rarity[];
|
|
105
143
|
export declare const RARITY_LABELS: Record<Rarity, string>;
|
|
106
|
-
/**
|
|
107
|
-
* Canonical tier colours, shared by every surface that renders the rarity
|
|
108
|
-
* scale (badge chips, legends) so the key always matches the badges it
|
|
109
|
-
* explains.
|
|
110
|
-
*/
|
|
111
|
-
export declare const RARITY_CHIP_CLASSES: Record<Rarity, string>;
|
|
112
144
|
/** The rarity tier a badge reaches when earned under the given run conditions. */
|
|
113
145
|
export declare function computeBadgeRarity(id: BadgeId, run: BadgeRunParams): Rarity;
|
|
114
146
|
/** Parse a stored badge-id JSON array; malformed/missing/unknown ids read as none. */
|
package/dist/badges.js
CHANGED
|
@@ -20,24 +20,34 @@ const MULTIPLAYER_ONLY = ["multiplayer"];
|
|
|
20
20
|
/** Display order, most prestigious first. */
|
|
21
21
|
export const BADGE_ORDER = [
|
|
22
22
|
"victor",
|
|
23
|
+
"undisputed",
|
|
23
24
|
"flawless",
|
|
24
25
|
"unstoppable",
|
|
26
|
+
"herculean",
|
|
25
27
|
"on-a-roll",
|
|
26
28
|
"survivor",
|
|
27
29
|
"giant-slayer",
|
|
30
|
+
"ascendant",
|
|
31
|
+
"cassandra",
|
|
28
32
|
"sharpshooter",
|
|
29
33
|
"podium",
|
|
30
34
|
"pantheon",
|
|
31
35
|
"specialist",
|
|
32
36
|
"polymath",
|
|
33
37
|
"phoenix",
|
|
38
|
+
"far-horizons",
|
|
39
|
+
"new-heights",
|
|
34
40
|
"strong-finish",
|
|
41
|
+
"strong-start",
|
|
42
|
+
"undaunted",
|
|
35
43
|
"master-of-none",
|
|
36
44
|
"faster-than-most",
|
|
37
45
|
"clutch",
|
|
38
46
|
"quick-draw",
|
|
39
|
-
"
|
|
47
|
+
"measured",
|
|
40
48
|
"sure-handed",
|
|
49
|
+
"decisive",
|
|
50
|
+
"lone-torch",
|
|
41
51
|
"unbroken-focus",
|
|
42
52
|
"devotee",
|
|
43
53
|
];
|
|
@@ -45,21 +55,21 @@ export const BADGES = {
|
|
|
45
55
|
victor: {
|
|
46
56
|
id: "victor",
|
|
47
57
|
label: "Victor",
|
|
48
|
-
description: "Topped the field in a multiplayer
|
|
58
|
+
description: "Topped the field in a multiplayer game.",
|
|
49
59
|
icon: "banner",
|
|
50
60
|
modes: MULTIPLAYER_ONLY,
|
|
51
61
|
},
|
|
52
62
|
podium: {
|
|
53
63
|
id: "podium",
|
|
54
64
|
label: "Podium",
|
|
55
|
-
description: "Finished
|
|
65
|
+
description: "Finished in the top three of a multiplayer game.",
|
|
56
66
|
icon: "steps",
|
|
57
67
|
modes: MULTIPLAYER_ONLY,
|
|
58
68
|
},
|
|
59
69
|
flawless: {
|
|
60
70
|
id: "flawless",
|
|
61
71
|
label: "Flawless",
|
|
62
|
-
description: "Cleared a
|
|
72
|
+
description: "Cleared a run without a single miss.",
|
|
63
73
|
icon: "wreath",
|
|
64
74
|
modes: CROSS_MODE,
|
|
65
75
|
},
|
|
@@ -87,91 +97,91 @@ export const BADGES = {
|
|
|
87
97
|
"giant-slayer": {
|
|
88
98
|
id: "giant-slayer",
|
|
89
99
|
label: "Giant slayer",
|
|
90
|
-
description: "Cleared a hard
|
|
100
|
+
description: "Cleared a hard run with 90% accuracy or better.",
|
|
91
101
|
icon: "empty-skull",
|
|
92
102
|
modes: CROSS_MODE,
|
|
93
103
|
},
|
|
94
104
|
pantheon: {
|
|
95
105
|
id: "pantheon",
|
|
96
106
|
label: "Pantheon",
|
|
97
|
-
description: "Aced two or more categories in a single
|
|
107
|
+
description: "Aced two or more categories in a single run.",
|
|
98
108
|
icon: "olympus",
|
|
99
109
|
modes: CROSS_MODE,
|
|
100
110
|
},
|
|
101
111
|
phoenix: {
|
|
102
112
|
id: "phoenix",
|
|
103
113
|
label: "Phoenix",
|
|
104
|
-
description: "Rallied to a
|
|
114
|
+
description: "Rallied to five in a row after a miss.",
|
|
105
115
|
icon: "flower-1",
|
|
106
116
|
modes: CROSS_MODE,
|
|
107
117
|
},
|
|
108
118
|
clutch: {
|
|
109
119
|
id: "clutch",
|
|
110
120
|
label: "Clutch",
|
|
111
|
-
description: "Landed answers in the dying moments
|
|
121
|
+
description: "Landed three answers in the clock's dying moments.",
|
|
112
122
|
icon: "clock",
|
|
113
123
|
modes: CROSS_MODE,
|
|
114
124
|
},
|
|
115
125
|
sharpshooter: {
|
|
116
126
|
id: "sharpshooter",
|
|
117
127
|
label: "Sharpshooter",
|
|
118
|
-
description: "Finished a
|
|
128
|
+
description: "Finished a run with 95% accuracy or better.",
|
|
119
129
|
icon: "rhombus",
|
|
120
130
|
modes: CROSS_MODE,
|
|
121
131
|
},
|
|
122
132
|
specialist: {
|
|
123
133
|
id: "specialist",
|
|
124
134
|
label: "Specialist",
|
|
125
|
-
description: "Aced every question in a category.",
|
|
135
|
+
description: "Aced every question in a category, four or more.",
|
|
126
136
|
icon: "owl",
|
|
127
137
|
modes: CROSS_MODE,
|
|
128
138
|
},
|
|
129
139
|
polymath: {
|
|
130
140
|
id: "polymath",
|
|
131
141
|
label: "Polymath",
|
|
132
|
-
description: "
|
|
142
|
+
description: "Scored 60% or better on easy, medium and hard in one run.",
|
|
133
143
|
icon: "scroll",
|
|
134
144
|
modes: CROSS_MODE,
|
|
135
145
|
},
|
|
136
146
|
"strong-finish": {
|
|
137
147
|
id: "strong-finish",
|
|
138
148
|
label: "Strong finish",
|
|
139
|
-
description: "Closed out a
|
|
149
|
+
description: "Closed out a run on five or more correct in a row.",
|
|
140
150
|
icon: "flags",
|
|
141
151
|
modes: CROSS_MODE,
|
|
142
152
|
},
|
|
143
153
|
"sure-handed": {
|
|
144
154
|
id: "sure-handed",
|
|
145
155
|
label: "Sure-handed",
|
|
146
|
-
description: "
|
|
156
|
+
description: "Answered four in five correctly before the clock's halfway mark.",
|
|
147
157
|
icon: "check",
|
|
148
|
-
modes:
|
|
158
|
+
modes: CROSS_MODE,
|
|
149
159
|
},
|
|
150
160
|
devotee: {
|
|
151
161
|
id: "devotee",
|
|
152
162
|
label: "Devotee",
|
|
153
|
-
description: "Returned to the oracle
|
|
163
|
+
description: "Returned to the oracle three days running.",
|
|
154
164
|
icon: "church",
|
|
155
165
|
modes: SOLO_ONLY,
|
|
156
166
|
},
|
|
157
167
|
"master-of-none": {
|
|
158
168
|
id: "master-of-none",
|
|
159
|
-
label: "
|
|
160
|
-
description: "Earned
|
|
169
|
+
label: "Many-sided",
|
|
170
|
+
description: "Earned four other badges in one run spanning four or more categories.",
|
|
161
171
|
icon: "dice",
|
|
162
172
|
modes: CROSS_MODE,
|
|
163
173
|
},
|
|
164
174
|
"faster-than-most": {
|
|
165
175
|
id: "faster-than-most",
|
|
166
|
-
label: "
|
|
167
|
-
description: "
|
|
176
|
+
label: "Wing-footed",
|
|
177
|
+
description: "Outran most earlier solvers on three questions in one trial.",
|
|
168
178
|
icon: "top-right",
|
|
169
179
|
modes: SOLO_ONLY,
|
|
170
180
|
},
|
|
171
181
|
"quick-draw": {
|
|
172
182
|
id: "quick-draw",
|
|
173
183
|
label: "Quick draw",
|
|
174
|
-
description: "
|
|
184
|
+
description: "Averaged under three seconds an answer.",
|
|
175
185
|
icon: "hourglass",
|
|
176
186
|
modes: CROSS_MODE,
|
|
177
187
|
},
|
|
@@ -185,10 +195,80 @@ export const BADGES = {
|
|
|
185
195
|
"unbroken-focus": {
|
|
186
196
|
id: "unbroken-focus",
|
|
187
197
|
label: "Unbroken focus",
|
|
188
|
-
description: "
|
|
198
|
+
description: "Stayed with the trial for ten or more questions.",
|
|
189
199
|
icon: "eye-1",
|
|
190
200
|
modes: SOLO_ONLY,
|
|
191
201
|
},
|
|
202
|
+
herculean: {
|
|
203
|
+
id: "herculean",
|
|
204
|
+
label: "Herculean",
|
|
205
|
+
description: "Answered every hard question correctly in a mixed-difficulty run.",
|
|
206
|
+
icon: "horn",
|
|
207
|
+
modes: CROSS_MODE,
|
|
208
|
+
},
|
|
209
|
+
undaunted: {
|
|
210
|
+
id: "undaunted",
|
|
211
|
+
label: "Undaunted",
|
|
212
|
+
description: "Never missed twice in a row across twelve or more questions.",
|
|
213
|
+
icon: "heart",
|
|
214
|
+
modes: CROSS_MODE,
|
|
215
|
+
},
|
|
216
|
+
measured: {
|
|
217
|
+
id: "measured",
|
|
218
|
+
label: "Measured",
|
|
219
|
+
description: "Took time to think and still kept 85% accuracy.",
|
|
220
|
+
icon: "difficulty",
|
|
221
|
+
modes: CROSS_MODE,
|
|
222
|
+
},
|
|
223
|
+
"strong-start": {
|
|
224
|
+
id: "strong-start",
|
|
225
|
+
label: "Strong start",
|
|
226
|
+
description: "Opened a run with five correct in a row.",
|
|
227
|
+
icon: "flag",
|
|
228
|
+
modes: CROSS_MODE,
|
|
229
|
+
},
|
|
230
|
+
"new-heights": {
|
|
231
|
+
id: "new-heights",
|
|
232
|
+
label: "New heights",
|
|
233
|
+
description: "Beat a best score set under the same trial settings.",
|
|
234
|
+
icon: "chevron-up",
|
|
235
|
+
modes: SOLO_ONLY,
|
|
236
|
+
},
|
|
237
|
+
"far-horizons": {
|
|
238
|
+
id: "far-horizons",
|
|
239
|
+
label: "Far horizons",
|
|
240
|
+
description: "Answered correctly in ten categories across many trials.",
|
|
241
|
+
icon: "north-star",
|
|
242
|
+
modes: SOLO_ONLY,
|
|
243
|
+
},
|
|
244
|
+
cassandra: {
|
|
245
|
+
id: "cassandra",
|
|
246
|
+
label: "Cassandra",
|
|
247
|
+
description: "Answered correctly where most of the field went wrong.",
|
|
248
|
+
icon: "eye-2",
|
|
249
|
+
modes: MULTIPLAYER_ONLY,
|
|
250
|
+
},
|
|
251
|
+
"lone-torch": {
|
|
252
|
+
id: "lone-torch",
|
|
253
|
+
label: "Lone torch",
|
|
254
|
+
description: "Stood alone with the right answer in a round.",
|
|
255
|
+
icon: "candle",
|
|
256
|
+
modes: MULTIPLAYER_ONLY,
|
|
257
|
+
},
|
|
258
|
+
ascendant: {
|
|
259
|
+
id: "ascendant",
|
|
260
|
+
label: "Ascendant",
|
|
261
|
+
description: "Climbed from the bottom half to the podium.",
|
|
262
|
+
icon: "sun",
|
|
263
|
+
modes: MULTIPLAYER_ONLY,
|
|
264
|
+
},
|
|
265
|
+
undisputed: {
|
|
266
|
+
id: "undisputed",
|
|
267
|
+
label: "Undisputed",
|
|
268
|
+
description: "Held first place from the second round to the last.",
|
|
269
|
+
icon: "sun-detail",
|
|
270
|
+
modes: MULTIPLAYER_ONLY,
|
|
271
|
+
},
|
|
192
272
|
};
|
|
193
273
|
// ── Thresholds ────────────────────────────────────────────────────────────────
|
|
194
274
|
// Tuned so a typical run earns ~1–2 badges, not a handful — the easy-to-stack
|
|
@@ -211,13 +291,17 @@ const QUICK_DRAW_AVG_MS = 3000;
|
|
|
211
291
|
const UNBROKEN_FOCUS_MIN_ANSWERED = 10;
|
|
212
292
|
/** Per-category correct count needed to claim specialist (must be 100%). */
|
|
213
293
|
const SPECIALIST_MIN_IN_CATEGORY = 4;
|
|
214
|
-
/**
|
|
215
|
-
const FASTER_THAN_MOST_PERCENT = 75;
|
|
294
|
+
/** An answer beating more than this % of prior solvers counts toward Wing-footed. */
|
|
295
|
+
export const FASTER_THAN_MOST_PERCENT = 75;
|
|
296
|
+
/** Minimum answered for Wing-footed. */
|
|
297
|
+
const WING_FOOTED_MIN_ANSWERED = 6;
|
|
298
|
+
/** Fast answers needed for Wing-footed. */
|
|
299
|
+
const WING_FOOTED_FAST_ANSWERS = 3;
|
|
216
300
|
/** Questions answered in an endless run that earns survivor. */
|
|
217
301
|
const SURVIVOR_QUESTIONS = 20;
|
|
218
|
-
/** Distinct categories a run must span for the
|
|
302
|
+
/** Distinct categories a run must span for the Many-sided meta-badge. */
|
|
219
303
|
const MASTER_OF_NONE_MIN_CATEGORIES = 3;
|
|
220
|
-
/** Other badges that must be earned alongside the breadth for
|
|
304
|
+
/** Other badges that must be earned alongside the breadth for Many-sided. */
|
|
221
305
|
const MASTER_OF_NONE_MIN_BADGES = 3;
|
|
222
306
|
/** Accuracy (0–1) on a hard run that earns giant-slayer. */
|
|
223
307
|
const GIANT_SLAYER_ACCURACY = 0.9;
|
|
@@ -231,8 +315,10 @@ const PHOENIX_STREAK = 5;
|
|
|
231
315
|
const CLUTCH_REMAINING_FRACTION = 0.1;
|
|
232
316
|
/** Clutch finishes needed across the run to earn the badge. */
|
|
233
317
|
const CLUTCH_MIN = 3;
|
|
234
|
-
/** Min answers before "sure-handed" (
|
|
318
|
+
/** Min answers before "sure-handed" (correct before halfway) is an achievement. */
|
|
235
319
|
const SURE_HANDED_MIN_ANSWERED = 8;
|
|
320
|
+
/** Share of answers (0–1) that must be correct before halfway for "sure-handed". */
|
|
321
|
+
const SURE_HANDED_SHARE = 0.8;
|
|
236
322
|
/** Trailing consecutive-correct answers a run must end on for "strong finish". */
|
|
237
323
|
const STRONG_FINISH_STREAK = 5;
|
|
238
324
|
/** Per-band accuracy (0–1) each difficulty must clear for "polymath". */
|
|
@@ -241,6 +327,28 @@ const POLYMATH_BAND_ACCURACY = 0.6;
|
|
|
241
327
|
const POLYMATH_MIN_PER_BAND = 2;
|
|
242
328
|
/** Consecutive days played that earns "devotee". */
|
|
243
329
|
const DEVOTEE_DAYS = 3;
|
|
330
|
+
/** Prior runs in a trial bucket needed before "new-heights" is a claim. */
|
|
331
|
+
const NEW_HEIGHTS_MIN_PRIOR_RUNS = 3;
|
|
332
|
+
/** Distinct categories answered correctly that earns "far-horizons". */
|
|
333
|
+
const FAR_HORIZONS_CATEGORIES = 10;
|
|
334
|
+
/** Hard-band answers a mixed run needs before "herculean" is a claim. */
|
|
335
|
+
const HERCULEAN_MIN_HARD = 4;
|
|
336
|
+
/** Answers floor for "undaunted". */
|
|
337
|
+
const UNDAUNTED_MIN_ANSWERED = 12;
|
|
338
|
+
/** Misses a run needs before "undaunted" says anything about recovery. */
|
|
339
|
+
const UNDAUNTED_MIN_MISSES = 3;
|
|
340
|
+
/** Accuracy (0–1) "undaunted" needs alongside its scattered misses. */
|
|
341
|
+
const UNDAUNTED_ACCURACY = 0.7;
|
|
342
|
+
/** Answers floor for "measured". */
|
|
343
|
+
const MEASURED_MIN_ANSWERED = 8;
|
|
344
|
+
/** Accuracy (0–1) "measured" needs. */
|
|
345
|
+
const MEASURED_ACCURACY = 0.85;
|
|
346
|
+
/** Highest decisive share (0–1) that still reads as taking time. */
|
|
347
|
+
const MEASURED_MAX_DECISIVE_SHARE = 0.3;
|
|
348
|
+
/** Leading correct answers that earn "strong start". */
|
|
349
|
+
const STRONG_START_STREAK = 5;
|
|
350
|
+
/** Answers floor for "strong start". */
|
|
351
|
+
const STRONG_START_MIN_ANSWERED = 8;
|
|
244
352
|
/** Heroes a multiplayer field needs before winning it means anything. */
|
|
245
353
|
const VICTOR_MIN_HEROES = 2;
|
|
246
354
|
/** Highest rank that still counts as a podium finish. */
|
|
@@ -249,6 +357,56 @@ const PODIUM_MAX_RANK = 3;
|
|
|
249
357
|
const PODIUM_MIN_HEROES = 4;
|
|
250
358
|
/** Served-questions floor for the rank badges (victor, podium) and lifetime best-rank — below this a rank isn't a claim. */
|
|
251
359
|
export const RANK_BADGE_MIN_QUESTIONS = 6;
|
|
360
|
+
/** Others a round needs for the field to be wrong "most of" (Cassandra). */
|
|
361
|
+
const CASSANDRA_MIN_OTHERS = 3;
|
|
362
|
+
/** Share (0–1) of the other players who must be wrong in a Cassandra round. */
|
|
363
|
+
const CASSANDRA_WRONG_SHARE = 0.6;
|
|
364
|
+
/** Rounds needed for Cassandra. */
|
|
365
|
+
const CASSANDRA_MIN_ROUNDS = 3;
|
|
366
|
+
/** Heroes a field needs for Cassandra. */
|
|
367
|
+
const CASSANDRA_MIN_HEROES = 4;
|
|
368
|
+
/** Heroes a field needs for Lone torch. */
|
|
369
|
+
const LONE_TORCH_MIN_HEROES = 5;
|
|
370
|
+
/** Others who must have submitted an answer in a Lone torch round. */
|
|
371
|
+
const LONE_TORCH_MIN_SUBMITTERS = 4;
|
|
372
|
+
/** Places Ascendant must climb from the midpoint snapshot. */
|
|
373
|
+
const ASCENDANT_MIN_CLIMB = 2;
|
|
374
|
+
/**
|
|
375
|
+
* Field-relative round counts for `playerId` from every tenured player's log.
|
|
376
|
+
* The field for a round is the other players' submitted answers; a miss-filled
|
|
377
|
+
* row (a leaver, or a timeout) is not a wrong answer and is left out.
|
|
378
|
+
*/
|
|
379
|
+
export function fieldRoundStats(playerId, logs) {
|
|
380
|
+
let fieldRoundsDefied = 0;
|
|
381
|
+
let soleCorrectRounds = 0;
|
|
382
|
+
for (const entry of logs.get(playerId) ?? []) {
|
|
383
|
+
if (!entry.isCorrect)
|
|
384
|
+
continue;
|
|
385
|
+
let others = 0;
|
|
386
|
+
let wrong = 0;
|
|
387
|
+
let othersCorrect = 0;
|
|
388
|
+
for (const [id, log] of logs) {
|
|
389
|
+
if (id === playerId)
|
|
390
|
+
continue;
|
|
391
|
+
const other = log.find((e) => e.questionIndex === entry.questionIndex);
|
|
392
|
+
if (!other?.submitted)
|
|
393
|
+
continue;
|
|
394
|
+
others++;
|
|
395
|
+
if (other.isCorrect)
|
|
396
|
+
othersCorrect++;
|
|
397
|
+
else
|
|
398
|
+
wrong++;
|
|
399
|
+
}
|
|
400
|
+
if (others >= CASSANDRA_MIN_OTHERS &&
|
|
401
|
+
wrong / others >= CASSANDRA_WRONG_SHARE) {
|
|
402
|
+
fieldRoundsDefied++;
|
|
403
|
+
}
|
|
404
|
+
if (othersCorrect === 0 && others >= LONE_TORCH_MIN_SUBMITTERS) {
|
|
405
|
+
soleCorrectRounds++;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return { fieldRoundsDefied, soleCorrectRounds };
|
|
409
|
+
}
|
|
252
410
|
function humanize(categoryId) {
|
|
253
411
|
return categoryId.replace(/-/g, " ");
|
|
254
412
|
}
|
|
@@ -325,6 +483,20 @@ function trailingStreak(input) {
|
|
|
325
483
|
}
|
|
326
484
|
return run;
|
|
327
485
|
}
|
|
486
|
+
/** True when any two consecutive answers were both misses. */
|
|
487
|
+
function hasConsecutiveMisses(input) {
|
|
488
|
+
for (let i = 1; i < input.results.length; i++) {
|
|
489
|
+
if (!input.results[i].isCorrect && !input.results[i - 1].isCorrect) {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
/** True when the run opened with `n` correct answers in a row. */
|
|
496
|
+
function openedWithStreak(input, n) {
|
|
497
|
+
return (input.results.length >= n &&
|
|
498
|
+
input.results.slice(0, n).every((r) => r.isCorrect));
|
|
499
|
+
}
|
|
328
500
|
/**
|
|
329
501
|
* True when the run spanned all three difficulty bands (each over its floor) and
|
|
330
502
|
* cleared the accuracy bar in every one — a polymath. Needs per-result
|
|
@@ -440,11 +612,11 @@ export function computeBadges(input) {
|
|
|
440
612
|
if (finish >= STRONG_FINISH_STREAK) {
|
|
441
613
|
awards.push({ id: "strong-finish", detail: `${finish} to close it out` });
|
|
442
614
|
}
|
|
443
|
-
if (input.
|
|
444
|
-
input.
|
|
615
|
+
if (input.totalAnswered >= WING_FOOTED_MIN_ANSWERED &&
|
|
616
|
+
input.fastAnswerCount >= WING_FOOTED_FAST_ANSWERS) {
|
|
445
617
|
awards.push({
|
|
446
618
|
id: "faster-than-most",
|
|
447
|
-
detail: `
|
|
619
|
+
detail: `outran most on ${input.fastAnswerCount} questions`,
|
|
448
620
|
});
|
|
449
621
|
}
|
|
450
622
|
// Clutch: repeatedly landed answers in the final slice of the clock.
|
|
@@ -459,26 +631,73 @@ export function computeBadges(input) {
|
|
|
459
631
|
detail: `${(input.timeToAnswerAvgMs / 1000).toFixed(1)}s average`,
|
|
460
632
|
});
|
|
461
633
|
}
|
|
634
|
+
// Sure-handed: four in five answers correct before halfway. Outranks
|
|
635
|
+
// decisive, which it implies.
|
|
636
|
+
const sureHandedShare = input.timerDurationMs > 0 && input.totalAnswered >= SURE_HANDED_MIN_ANSWERED
|
|
637
|
+
? input.results.filter((r) => r.isCorrect && r.timeToAnswerMs <= input.timerDurationMs / 2).length / input.totalAnswered
|
|
638
|
+
: 0;
|
|
639
|
+
const sureHanded = sureHandedShare >= SURE_HANDED_SHARE;
|
|
640
|
+
if (sureHanded) {
|
|
641
|
+
awards.push({
|
|
642
|
+
id: "sure-handed",
|
|
643
|
+
detail: `${percent(sureHandedShare)}% right before halfway`,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
462
646
|
const share = decisiveShare(input);
|
|
463
|
-
if (
|
|
647
|
+
if (!sureHanded &&
|
|
648
|
+
share !== null &&
|
|
464
649
|
share >= DECISIVE_THRESHOLD &&
|
|
465
650
|
input.totalAnswered >= RATE_BADGE_MIN_ANSWERED) {
|
|
466
651
|
const pct = percent(share);
|
|
467
652
|
awards.push({ id: "decisive", detail: `${pct}% before halfway` });
|
|
468
653
|
}
|
|
469
|
-
// Sure-handed: committed every answer first-try, no second-guessing (STATS-7).
|
|
470
|
-
if (input.answerChanges === 0 &&
|
|
471
|
-
input.totalAnswered >= SURE_HANDED_MIN_ANSWERED) {
|
|
472
|
-
awards.push({ id: "sure-handed", detail: "no answer ever changed" });
|
|
473
|
-
}
|
|
474
654
|
if (input.totalAnswered >= UNBROKEN_FOCUS_MIN_ANSWERED &&
|
|
475
655
|
input.tabAwayCount === 0) {
|
|
476
656
|
awards.push({ id: "unbroken-focus", detail: "never left the trial" });
|
|
477
657
|
}
|
|
658
|
+
// Herculean: every hard question right in a mixed-difficulty run.
|
|
659
|
+
const hardResults = input.results.filter((r) => r.difficulty === "hard");
|
|
660
|
+
if (input.difficulty === "all" &&
|
|
661
|
+
hardResults.length >= HERCULEAN_MIN_HARD &&
|
|
662
|
+
hardResults.every((r) => r.isCorrect)) {
|
|
663
|
+
awards.push({
|
|
664
|
+
id: "herculean",
|
|
665
|
+
detail: `${hardResults.length} hard, all correct`,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// Undaunted: a long run whose misses never came back-to-back.
|
|
669
|
+
const misses = input.totalAnswered - input.correctCount;
|
|
670
|
+
if (input.totalAnswered >= UNDAUNTED_MIN_ANSWERED &&
|
|
671
|
+
misses >= UNDAUNTED_MIN_MISSES &&
|
|
672
|
+
accuracy >= UNDAUNTED_ACCURACY &&
|
|
673
|
+
!hasConsecutiveMisses(input)) {
|
|
674
|
+
awards.push({
|
|
675
|
+
id: "undaunted",
|
|
676
|
+
detail: `${misses} misses, never two in a row`,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
// Measured: high accuracy while taking time on most answers.
|
|
680
|
+
if (input.totalAnswered >= MEASURED_MIN_ANSWERED &&
|
|
681
|
+
accuracy >= MEASURED_ACCURACY &&
|
|
682
|
+
share !== null &&
|
|
683
|
+
share <= MEASURED_MAX_DECISIVE_SHARE) {
|
|
684
|
+
awards.push({
|
|
685
|
+
id: "measured",
|
|
686
|
+
detail: `${percent(accuracy)}% accuracy, unhurried`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
// Strong start: opened the run on five correct in a row.
|
|
690
|
+
if (input.totalAnswered >= STRONG_START_MIN_ANSWERED &&
|
|
691
|
+
openedWithStreak(input, STRONG_START_STREAK)) {
|
|
692
|
+
awards.push({
|
|
693
|
+
id: "strong-start",
|
|
694
|
+
detail: `${STRONG_START_STREAK} to open`,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
478
697
|
// Mode tags (ADR 0018): only badges awardable in this mode survive. Applied
|
|
479
698
|
// before the meta-badge so master-of-none never counts a filtered honour.
|
|
480
699
|
const eligible = awards.filter((a) => BADGES[a.id].modes.includes(context.kind));
|
|
481
|
-
//
|
|
700
|
+
// Many-sided (meta): broad play that still racked up honours — jack of
|
|
482
701
|
// all trades. Counts the badges earned *above*, so it never counts itself.
|
|
483
702
|
const distinctCategories = new Set(input.results.map((r) => r.categoryId))
|
|
484
703
|
.size;
|
|
@@ -489,6 +708,48 @@ export function computeBadges(input) {
|
|
|
489
708
|
detail: `${distinctCategories} categories spanned`,
|
|
490
709
|
});
|
|
491
710
|
}
|
|
711
|
+
// Field badges (multiplayer): derived from the game's answer log and per-round
|
|
712
|
+
// rank snapshots. Added after the meta so they never count toward it.
|
|
713
|
+
if (context.kind === "multiplayer") {
|
|
714
|
+
const victorGates = context.score > 0 &&
|
|
715
|
+
context.heroCount >= VICTOR_MIN_HEROES &&
|
|
716
|
+
context.questionsServed >= RANK_BADGE_MIN_QUESTIONS;
|
|
717
|
+
const podiumGates = context.score > 0 &&
|
|
718
|
+
context.heroCount >= PODIUM_MIN_HEROES &&
|
|
719
|
+
context.questionsServed >= RANK_BADGE_MIN_QUESTIONS;
|
|
720
|
+
const history = context.rankHistory ?? [];
|
|
721
|
+
const defied = context.fieldRoundsDefied ?? 0;
|
|
722
|
+
const cassandra = context.heroCount >= CASSANDRA_MIN_HEROES &&
|
|
723
|
+
defied >= CASSANDRA_MIN_ROUNDS;
|
|
724
|
+
if (cassandra) {
|
|
725
|
+
eligible.push({
|
|
726
|
+
id: "cassandra",
|
|
727
|
+
detail: `right against the field ${defied}×`,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
if (!cassandra &&
|
|
731
|
+
context.heroCount >= LONE_TORCH_MIN_HEROES &&
|
|
732
|
+
(context.soleCorrectRounds ?? 0) >= 1) {
|
|
733
|
+
eligible.push({ id: "lone-torch", detail: "only one right in a round" });
|
|
734
|
+
}
|
|
735
|
+
const mid = history[Math.floor((history.length - 1) / 2)];
|
|
736
|
+
if (podiumGates &&
|
|
737
|
+
context.rank <= PODIUM_MAX_RANK &&
|
|
738
|
+
mid &&
|
|
739
|
+
mid.rank > Math.ceil(mid.field / 2) &&
|
|
740
|
+
mid.rank - context.rank >= ASCENDANT_MIN_CLIMB) {
|
|
741
|
+
eligible.push({
|
|
742
|
+
id: "ascendant",
|
|
743
|
+
detail: `#${mid.rank} at halfway, finished #${context.rank}`,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
if (victorGates &&
|
|
747
|
+
context.rank === 1 &&
|
|
748
|
+
history.length >= 2 &&
|
|
749
|
+
history.slice(1).every((h) => h?.rank === 1)) {
|
|
750
|
+
eligible.push({ id: "undisputed", detail: "first from round two" });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
492
753
|
// Devotee: a consistency honour for returning on consecutive days (STATS-9).
|
|
493
754
|
// Cross-run, so it's gated on the player's day streak (signed-in only) rather
|
|
494
755
|
// than this run's play. Added after the meta so it never counts toward it.
|
|
@@ -500,6 +761,35 @@ export function computeBadges(input) {
|
|
|
500
761
|
detail: `${input.consecutiveDays} days running`,
|
|
501
762
|
});
|
|
502
763
|
}
|
|
764
|
+
// New heights: a personal best in the same trial bucket, after enough prior
|
|
765
|
+
// runs to make it a claim. Cross-run, so appended after the meta-count.
|
|
766
|
+
if (context.kind === "solo" &&
|
|
767
|
+
context.mode === "normal" &&
|
|
768
|
+
input.runScore !== undefined &&
|
|
769
|
+
input.bucketBest != null &&
|
|
770
|
+
(input.bucketPriorRuns ?? 0) >= NEW_HEIGHTS_MIN_PRIOR_RUNS &&
|
|
771
|
+
input.runScore > input.bucketBest) {
|
|
772
|
+
eligible.push({
|
|
773
|
+
id: "new-heights",
|
|
774
|
+
detail: `${input.runScore} beat ${input.bucketBest}`,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
// Far horizons: awarded once, on the run that lifts the player's distinct
|
|
778
|
+
// correctly-answered categories to the threshold.
|
|
779
|
+
if (context.kind === "solo" && input.priorCorrectCategories !== undefined) {
|
|
780
|
+
const prior = new Set(input.priorCorrectCategories);
|
|
781
|
+
const total = new Set(prior);
|
|
782
|
+
for (const r of input.results)
|
|
783
|
+
if (r.isCorrect)
|
|
784
|
+
total.add(r.categoryId);
|
|
785
|
+
if (prior.size < FAR_HORIZONS_CATEGORIES &&
|
|
786
|
+
total.size >= FAR_HORIZONS_CATEGORIES) {
|
|
787
|
+
eligible.push({
|
|
788
|
+
id: "far-horizons",
|
|
789
|
+
detail: `${total.size} categories mastered`,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
503
793
|
const rank = new Map(BADGE_ORDER.map((id, i) => [id, i]));
|
|
504
794
|
return (eligible
|
|
505
795
|
// The mode tags are the authoritative gate (ADR 0018): a final pass so
|
|
@@ -514,17 +804,6 @@ export const RARITY_LABELS = {
|
|
|
514
804
|
legendary: "Legendary",
|
|
515
805
|
exotic: "Exotic",
|
|
516
806
|
};
|
|
517
|
-
/**
|
|
518
|
-
* Canonical tier colours, shared by every surface that renders the rarity
|
|
519
|
-
* scale (badge chips, legends) so the key always matches the badges it
|
|
520
|
-
* explains.
|
|
521
|
-
*/
|
|
522
|
-
export const RARITY_CHIP_CLASSES = {
|
|
523
|
-
common: "border-zinc-500/40 bg-zinc-500/10 text-zinc-200",
|
|
524
|
-
rare: "border-blue-500/40 bg-blue-500/10 text-blue-200",
|
|
525
|
-
legendary: "border-violet-500/50 bg-violet-500/10 text-violet-200",
|
|
526
|
-
exotic: "border-gold bg-gold/20 text-gold-lighter shadow-[0_0_12px_-2px] shadow-gold/40",
|
|
527
|
-
};
|
|
528
807
|
const RARITY_RULES = {
|
|
529
808
|
victor: { base: 1, heroes: true, difficulty: true },
|
|
530
809
|
podium: { base: 0, heroes: true, difficulty: true },
|
|
@@ -553,15 +832,30 @@ const RARITY_RULES = {
|
|
|
553
832
|
timer: "shorter",
|
|
554
833
|
difficulty: true,
|
|
555
834
|
},
|
|
556
|
-
"sure-handed": {
|
|
835
|
+
"sure-handed": {
|
|
836
|
+
base: 1,
|
|
837
|
+
questions: true,
|
|
838
|
+
timer: "shorter",
|
|
839
|
+
difficulty: true,
|
|
840
|
+
},
|
|
557
841
|
devotee: { base: 1 },
|
|
558
842
|
phoenix: { base: 1, difficulty: true },
|
|
559
843
|
"master-of-none": { base: 2, difficulty: true },
|
|
560
844
|
clutch: { base: 1, questions: true, difficulty: true },
|
|
561
|
-
"faster-than-most": { base: 1,
|
|
845
|
+
"faster-than-most": { base: 1, difficulty: true },
|
|
562
846
|
"quick-draw": { base: 1, questions: true, timer: "longer", difficulty: true },
|
|
563
847
|
decisive: { base: 0, questions: true, timer: "shorter", difficulty: true },
|
|
564
848
|
"unbroken-focus": { base: 0, questions: true, difficulty: true },
|
|
849
|
+
herculean: { base: 2, difficulty: true },
|
|
850
|
+
undaunted: { base: 0, questions: true, difficulty: true },
|
|
851
|
+
measured: { base: 0, questions: true, difficulty: true },
|
|
852
|
+
"strong-start": { base: 0, timer: "shorter", difficulty: true },
|
|
853
|
+
"new-heights": { base: 1, difficulty: true },
|
|
854
|
+
"far-horizons": { base: 1 },
|
|
855
|
+
cassandra: { base: 1, heroes: true, difficulty: true },
|
|
856
|
+
"lone-torch": { base: 0, heroes: true, difficulty: true },
|
|
857
|
+
ascendant: { base: 1, heroes: true },
|
|
858
|
+
undisputed: { base: 2, heroes: true, difficulty: true },
|
|
565
859
|
};
|
|
566
860
|
/** More questions than the default 10 → harder to sustain (0–2). */
|
|
567
861
|
function questionLevel(count) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orakl/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Orakl runtime helpers shared by the web client and the engine.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"
|
|
21
|
-
"
|
|
20
|
+
"date-fns": "^4.4.0",
|
|
21
|
+
"@orakl/protocol": "0.2.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^26.4.1",
|