@orakl/shared 0.1.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/LICENSE +662 -0
- package/dist/analytics.d.ts +21 -0
- package/dist/analytics.js +21 -0
- package/dist/avatar-helpers.d.ts +46 -0
- package/dist/avatar-helpers.js +69 -0
- package/dist/avatars.d.ts +6 -0
- package/dist/avatars.js +138 -0
- package/dist/badges.d.ts +126 -0
- package/dist/badges.js +667 -0
- package/dist/constants/auth.constants.d.ts +26 -0
- package/dist/constants/auth.constants.js +94 -0
- package/dist/constants/feature-flags.constants.d.ts +138 -0
- package/dist/constants/feature-flags.constants.js +119 -0
- package/dist/constants/font.constants.d.ts +9 -0
- package/dist/constants/font.constants.js +14 -0
- package/dist/constants/lobby.constants.d.ts +2 -0
- package/dist/constants/lobby.constants.js +438 -0
- package/dist/constants/patterns.constants.d.ts +4 -0
- package/dist/constants/patterns.constants.js +4 -0
- package/dist/constants/theme.constants.d.ts +24 -0
- package/dist/constants/theme.constants.js +69 -0
- package/dist/env.d.ts +3 -0
- package/dist/env.js +5 -0
- package/dist/font.d.ts +4 -0
- package/dist/font.js +8 -0
- package/dist/history-range.d.ts +13 -0
- package/dist/history-range.js +29 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +26 -0
- package/dist/lobby.d.ts +16 -0
- package/dist/lobby.js +50 -0
- package/dist/manage-questions.d.ts +52 -0
- package/dist/manage-questions.js +26 -0
- package/dist/math.d.ts +27 -0
- package/dist/math.js +30 -0
- package/dist/murmurhash.d.ts +9 -0
- package/dist/murmurhash.js +52 -0
- package/dist/pricing.d.ts +32 -0
- package/dist/pricing.js +32 -0
- package/dist/profile-tabs.d.ts +8 -0
- package/dist/profile-tabs.js +18 -0
- package/dist/quiz-routes.d.ts +28 -0
- package/dist/quiz-routes.js +65 -0
- package/dist/sanitize.d.ts +38 -0
- package/dist/sanitize.js +109 -0
- package/dist/schemas/question.schema.d.ts +1 -0
- package/dist/schemas/question.schema.js +1 -0
- package/dist/solo-entitlements.d.ts +28 -0
- package/dist/solo-entitlements.js +56 -0
- package/dist/theme.d.ts +39 -0
- package/dist/theme.js +139 -0
- package/dist/types/auth.d.ts +143 -0
- package/dist/types/auth.js +28 -0
- package/dist/utils.d.ts +50 -0
- package/dist/utils.js +133 -0
- package/package.json +45 -0
package/dist/badges.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
// Achievement badges. A badge is an honour earned for *how* a run was played
|
|
2
|
+
// (not just the score) — a clean sweep, precision, a hot streak, speed, focus,
|
|
3
|
+
// survival, a podium finish. One canonical registry serves every mode: each
|
|
4
|
+
// badge is tagged with the modes that can award it (cross-mode, solo-only or
|
|
5
|
+
// multiplayer-only — ADR 0018). Earned badges are surfaced on the results
|
|
6
|
+
// screen and tallied across runs on the player's trials page ("No hesitation
|
|
7
|
+
// ×3"). Computation is a pure function so it stays testable and is the single
|
|
8
|
+
// source of truth for server (authoritative award + persistence) and client
|
|
9
|
+
// (display).
|
|
10
|
+
//
|
|
11
|
+
// Rarity is hybrid: each badge has a base tier, then the run's conditions (more
|
|
12
|
+
// questions, shorter/longer timer, harder difficulty, a bigger field of heroes)
|
|
13
|
+
// bump it up — a Flawless on 30 questions at a 5s clock is rarer than one on
|
|
14
|
+
// the default 10 at 60s. Rarity is fully derivable from the run's parameters,
|
|
15
|
+
// so it's recomputed wherever a badge renders rather than stored.
|
|
16
|
+
import { percent } from "./math.js";
|
|
17
|
+
const CROSS_MODE = ["solo", "multiplayer"];
|
|
18
|
+
const SOLO_ONLY = ["solo"];
|
|
19
|
+
const MULTIPLAYER_ONLY = ["multiplayer"];
|
|
20
|
+
/** Display order, most prestigious first. */
|
|
21
|
+
export const BADGE_ORDER = [
|
|
22
|
+
"victor",
|
|
23
|
+
"flawless",
|
|
24
|
+
"unstoppable",
|
|
25
|
+
"on-a-roll",
|
|
26
|
+
"survivor",
|
|
27
|
+
"giant-slayer",
|
|
28
|
+
"sharpshooter",
|
|
29
|
+
"podium",
|
|
30
|
+
"pantheon",
|
|
31
|
+
"specialist",
|
|
32
|
+
"polymath",
|
|
33
|
+
"phoenix",
|
|
34
|
+
"strong-finish",
|
|
35
|
+
"master-of-none",
|
|
36
|
+
"faster-than-most",
|
|
37
|
+
"clutch",
|
|
38
|
+
"quick-draw",
|
|
39
|
+
"decisive",
|
|
40
|
+
"sure-handed",
|
|
41
|
+
"unbroken-focus",
|
|
42
|
+
"devotee",
|
|
43
|
+
];
|
|
44
|
+
export const BADGES = {
|
|
45
|
+
victor: {
|
|
46
|
+
id: "victor",
|
|
47
|
+
label: "Victor",
|
|
48
|
+
description: "Topped the field in a multiplayer quiz.",
|
|
49
|
+
icon: "banner",
|
|
50
|
+
modes: MULTIPLAYER_ONLY,
|
|
51
|
+
},
|
|
52
|
+
podium: {
|
|
53
|
+
id: "podium",
|
|
54
|
+
label: "Podium",
|
|
55
|
+
description: "Finished among the top three in a multiplayer quiz.",
|
|
56
|
+
icon: "steps",
|
|
57
|
+
modes: MULTIPLAYER_ONLY,
|
|
58
|
+
},
|
|
59
|
+
flawless: {
|
|
60
|
+
id: "flawless",
|
|
61
|
+
label: "Flawless",
|
|
62
|
+
description: "Cleared a trial without a single miss.",
|
|
63
|
+
icon: "wreath",
|
|
64
|
+
modes: CROSS_MODE,
|
|
65
|
+
},
|
|
66
|
+
unstoppable: {
|
|
67
|
+
id: "unstoppable",
|
|
68
|
+
label: "Unstoppable",
|
|
69
|
+
description: "Strung together a streak of ten or more.",
|
|
70
|
+
icon: "stars",
|
|
71
|
+
modes: CROSS_MODE,
|
|
72
|
+
},
|
|
73
|
+
"on-a-roll": {
|
|
74
|
+
id: "on-a-roll",
|
|
75
|
+
label: "On a roll",
|
|
76
|
+
description: "Strung together a streak of six or more.",
|
|
77
|
+
icon: "flame",
|
|
78
|
+
modes: CROSS_MODE,
|
|
79
|
+
},
|
|
80
|
+
survivor: {
|
|
81
|
+
id: "survivor",
|
|
82
|
+
label: "Survivor",
|
|
83
|
+
description: "Endured twenty or more questions in an endless run.",
|
|
84
|
+
icon: "shield",
|
|
85
|
+
modes: SOLO_ONLY,
|
|
86
|
+
},
|
|
87
|
+
"giant-slayer": {
|
|
88
|
+
id: "giant-slayer",
|
|
89
|
+
label: "Giant slayer",
|
|
90
|
+
description: "Cleared a hard-difficulty trial with commanding accuracy.",
|
|
91
|
+
icon: "empty-skull",
|
|
92
|
+
modes: CROSS_MODE,
|
|
93
|
+
},
|
|
94
|
+
pantheon: {
|
|
95
|
+
id: "pantheon",
|
|
96
|
+
label: "Pantheon",
|
|
97
|
+
description: "Aced two or more categories in a single trial.",
|
|
98
|
+
icon: "olympus",
|
|
99
|
+
modes: CROSS_MODE,
|
|
100
|
+
},
|
|
101
|
+
phoenix: {
|
|
102
|
+
id: "phoenix",
|
|
103
|
+
label: "Phoenix",
|
|
104
|
+
description: "Rallied to a long streak after an early stumble.",
|
|
105
|
+
icon: "flower-1",
|
|
106
|
+
modes: CROSS_MODE,
|
|
107
|
+
},
|
|
108
|
+
clutch: {
|
|
109
|
+
id: "clutch",
|
|
110
|
+
label: "Clutch",
|
|
111
|
+
description: "Landed answers in the dying moments of the clock.",
|
|
112
|
+
icon: "clock",
|
|
113
|
+
modes: CROSS_MODE,
|
|
114
|
+
},
|
|
115
|
+
sharpshooter: {
|
|
116
|
+
id: "sharpshooter",
|
|
117
|
+
label: "Sharpshooter",
|
|
118
|
+
description: "Finished a trial with near-perfect accuracy.",
|
|
119
|
+
icon: "rhombus",
|
|
120
|
+
modes: CROSS_MODE,
|
|
121
|
+
},
|
|
122
|
+
specialist: {
|
|
123
|
+
id: "specialist",
|
|
124
|
+
label: "Specialist",
|
|
125
|
+
description: "Aced every question in a category.",
|
|
126
|
+
icon: "owl",
|
|
127
|
+
modes: CROSS_MODE,
|
|
128
|
+
},
|
|
129
|
+
polymath: {
|
|
130
|
+
id: "polymath",
|
|
131
|
+
label: "Polymath",
|
|
132
|
+
description: "Answered well across easy, medium and hard in one trial.",
|
|
133
|
+
icon: "scroll",
|
|
134
|
+
modes: CROSS_MODE,
|
|
135
|
+
},
|
|
136
|
+
"strong-finish": {
|
|
137
|
+
id: "strong-finish",
|
|
138
|
+
label: "Strong finish",
|
|
139
|
+
description: "Closed out a trial on a long unbroken streak.",
|
|
140
|
+
icon: "flags",
|
|
141
|
+
modes: CROSS_MODE,
|
|
142
|
+
},
|
|
143
|
+
"sure-handed": {
|
|
144
|
+
id: "sure-handed",
|
|
145
|
+
label: "Sure-handed",
|
|
146
|
+
description: "Committed every answer without second-guessing.",
|
|
147
|
+
icon: "check",
|
|
148
|
+
modes: SOLO_ONLY,
|
|
149
|
+
},
|
|
150
|
+
devotee: {
|
|
151
|
+
id: "devotee",
|
|
152
|
+
label: "Devotee",
|
|
153
|
+
description: "Returned to the oracle on consecutive days.",
|
|
154
|
+
icon: "church",
|
|
155
|
+
modes: SOLO_ONLY,
|
|
156
|
+
},
|
|
157
|
+
"master-of-none": {
|
|
158
|
+
id: "master-of-none",
|
|
159
|
+
label: "Master of none",
|
|
160
|
+
description: "Earned a spread of honours across many categories at once.",
|
|
161
|
+
icon: "dice",
|
|
162
|
+
modes: CROSS_MODE,
|
|
163
|
+
},
|
|
164
|
+
"faster-than-most": {
|
|
165
|
+
id: "faster-than-most",
|
|
166
|
+
label: "Faster than most",
|
|
167
|
+
description: "Beat most other players' time on a question.",
|
|
168
|
+
icon: "top-right",
|
|
169
|
+
modes: SOLO_ONLY,
|
|
170
|
+
},
|
|
171
|
+
"quick-draw": {
|
|
172
|
+
id: "quick-draw",
|
|
173
|
+
label: "Quick draw",
|
|
174
|
+
description: "Answered with a lightning-fast average time.",
|
|
175
|
+
icon: "hourglass",
|
|
176
|
+
modes: CROSS_MODE,
|
|
177
|
+
},
|
|
178
|
+
decisive: {
|
|
179
|
+
id: "decisive",
|
|
180
|
+
label: "No hesitation",
|
|
181
|
+
description: "Committed most answers before the clock's halfway mark.",
|
|
182
|
+
icon: "star",
|
|
183
|
+
modes: CROSS_MODE,
|
|
184
|
+
},
|
|
185
|
+
"unbroken-focus": {
|
|
186
|
+
id: "unbroken-focus",
|
|
187
|
+
label: "Unbroken focus",
|
|
188
|
+
description: "Never left the trial.",
|
|
189
|
+
icon: "eye-1",
|
|
190
|
+
modes: SOLO_ONLY,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
// ── Thresholds ────────────────────────────────────────────────────────────────
|
|
194
|
+
// Tuned so a typical run earns ~1–2 badges, not a handful — the easy-to-stack
|
|
195
|
+
// ones (decisive, quick-draw, focus) carry the higher bars.
|
|
196
|
+
/** Share of answers (0–1) before halfway needed to read as decisive. */
|
|
197
|
+
const DECISIVE_THRESHOLD = 0.7;
|
|
198
|
+
/** Peak streak that earns "on a roll". */
|
|
199
|
+
const ON_A_ROLL_STREAK = 6;
|
|
200
|
+
/** Peak streak that earns the higher-tier "unstoppable" (suppresses on-a-roll). */
|
|
201
|
+
const UNSTOPPABLE_STREAK = 10;
|
|
202
|
+
/** Accuracy (0–1) for sharpshooter — near-perfect but short of flawless. */
|
|
203
|
+
const SHARPSHOOTER_ACCURACY = 0.95;
|
|
204
|
+
/** Minimum answers before sharpshooter is meaningful. */
|
|
205
|
+
const SHARPSHOOTER_MIN_ANSWERED = 6;
|
|
206
|
+
/** Answers floor for the rate badges (quick-draw, decisive) — below this a timing average or halfway share isn't a claim. */
|
|
207
|
+
const RATE_BADGE_MIN_ANSWERED = 6;
|
|
208
|
+
/** Mean time-to-answer (ms) under which a run is a quick draw. */
|
|
209
|
+
const QUICK_DRAW_AVG_MS = 3000;
|
|
210
|
+
/** Minimum answers before "unbroken focus" is an achievement, not a freebie. */
|
|
211
|
+
const UNBROKEN_FOCUS_MIN_ANSWERED = 10;
|
|
212
|
+
/** Per-category correct count needed to claim specialist (must be 100%). */
|
|
213
|
+
const SPECIALIST_MIN_IN_CATEGORY = 4;
|
|
214
|
+
/** A single answer beating more than this % of prior solvers earns faster-than-most. */
|
|
215
|
+
const FASTER_THAN_MOST_PERCENT = 75;
|
|
216
|
+
/** Questions answered in an endless run that earns survivor. */
|
|
217
|
+
const SURVIVOR_QUESTIONS = 20;
|
|
218
|
+
/** Distinct categories a run must span for the "master of none" meta-badge. */
|
|
219
|
+
const MASTER_OF_NONE_MIN_CATEGORIES = 3;
|
|
220
|
+
/** Other badges that must be earned alongside the breadth for "master of none". */
|
|
221
|
+
const MASTER_OF_NONE_MIN_BADGES = 3;
|
|
222
|
+
/** Accuracy (0–1) on a hard run that earns giant-slayer. */
|
|
223
|
+
const GIANT_SLAYER_ACCURACY = 0.9;
|
|
224
|
+
/** Minimum answers before giant-slayer is meaningful. */
|
|
225
|
+
const GIANT_SLAYER_MIN_ANSWERED = 6;
|
|
226
|
+
/** Aced categories needed for the higher-tier "pantheon" (suppresses specialist). */
|
|
227
|
+
const PANTHEON_MIN_CATEGORIES = 2;
|
|
228
|
+
/** Length of a post-stumble recovery streak that earns phoenix. */
|
|
229
|
+
const PHOENIX_STREAK = 5;
|
|
230
|
+
/** Share of the clock (0–1) left when an answer counts as a clutch finish. */
|
|
231
|
+
const CLUTCH_REMAINING_FRACTION = 0.1;
|
|
232
|
+
/** Clutch finishes needed across the run to earn the badge. */
|
|
233
|
+
const CLUTCH_MIN = 3;
|
|
234
|
+
/** Min answers before "sure-handed" (never changed an answer) is an achievement. */
|
|
235
|
+
const SURE_HANDED_MIN_ANSWERED = 8;
|
|
236
|
+
/** Trailing consecutive-correct answers a run must end on for "strong finish". */
|
|
237
|
+
const STRONG_FINISH_STREAK = 5;
|
|
238
|
+
/** Per-band accuracy (0–1) each difficulty must clear for "polymath". */
|
|
239
|
+
const POLYMATH_BAND_ACCURACY = 0.6;
|
|
240
|
+
/** Min questions in a band before it counts toward "polymath". */
|
|
241
|
+
const POLYMATH_MIN_PER_BAND = 2;
|
|
242
|
+
/** Consecutive days played that earns "devotee". */
|
|
243
|
+
const DEVOTEE_DAYS = 3;
|
|
244
|
+
/** Heroes a multiplayer field needs before winning it means anything. */
|
|
245
|
+
const VICTOR_MIN_HEROES = 2;
|
|
246
|
+
/** Highest rank that still counts as a podium finish. */
|
|
247
|
+
const PODIUM_MAX_RANK = 3;
|
|
248
|
+
/** Heroes needed before a podium finish is an achievement (someone missed it). */
|
|
249
|
+
const PODIUM_MIN_HEROES = 4;
|
|
250
|
+
/** Served-questions floor for the rank badges (victor, podium) and lifetime best-rank — below this a rank isn't a claim. */
|
|
251
|
+
export const RANK_BADGE_MIN_QUESTIONS = 6;
|
|
252
|
+
function humanize(categoryId) {
|
|
253
|
+
return categoryId.replace(/-/g, " ");
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Raw share (0–1) of answers committed before the clock's halfway mark — quick
|
|
257
|
+
* answers read as decisive (implicit confidence from timing). Null when not
|
|
258
|
+
* measurable. This is the value the badge gates on (the rounded percent below
|
|
259
|
+
* is display-only, and rounding up could award the badge under the threshold).
|
|
260
|
+
*/
|
|
261
|
+
export function decisiveShare(input) {
|
|
262
|
+
if (input.results.length === 0 || input.timerDurationMs <= 0)
|
|
263
|
+
return null;
|
|
264
|
+
const half = input.timerDurationMs / 2;
|
|
265
|
+
const decisive = input.results.filter((r) => r.timeToAnswerMs <= half).length;
|
|
266
|
+
return decisive / input.results.length;
|
|
267
|
+
}
|
|
268
|
+
/** Rounded percentage form of {@link decisiveShare}, for display. */
|
|
269
|
+
export function decisivenessPercent(input) {
|
|
270
|
+
const share = decisiveShare(input);
|
|
271
|
+
return share === null ? null : percent(share);
|
|
272
|
+
}
|
|
273
|
+
/** Categories aced this run (100% correct over the specialist floor). */
|
|
274
|
+
function acedCategories(input) {
|
|
275
|
+
const tally = new Map();
|
|
276
|
+
for (const r of input.results) {
|
|
277
|
+
const e = tally.get(r.categoryId) ?? { correct: 0, total: 0 };
|
|
278
|
+
e.total++;
|
|
279
|
+
if (r.isCorrect)
|
|
280
|
+
e.correct++;
|
|
281
|
+
tally.set(r.categoryId, e);
|
|
282
|
+
}
|
|
283
|
+
const aced = [];
|
|
284
|
+
for (const [id, v] of tally) {
|
|
285
|
+
if (v.total >= SPECIALIST_MIN_IN_CATEGORY && v.correct === v.total) {
|
|
286
|
+
aced.push(id);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return aced;
|
|
290
|
+
}
|
|
291
|
+
/** Longest run of consecutive correct answers that began after an earlier miss. */
|
|
292
|
+
function comebackStreak(input) {
|
|
293
|
+
let sawMiss = false;
|
|
294
|
+
let run = 0;
|
|
295
|
+
let best = 0;
|
|
296
|
+
for (const r of input.results) {
|
|
297
|
+
if (!r.isCorrect) {
|
|
298
|
+
sawMiss = true;
|
|
299
|
+
run = 0;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
run++;
|
|
303
|
+
if (sawMiss)
|
|
304
|
+
best = Math.max(best, run);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return best;
|
|
308
|
+
}
|
|
309
|
+
/** Correct answers committed in the final slice of the clock (clutch finishes). */
|
|
310
|
+
function clutchFinishes(input) {
|
|
311
|
+
if (input.timerDurationMs <= 0)
|
|
312
|
+
return 0;
|
|
313
|
+
const cutoff = input.timerDurationMs * (1 - CLUTCH_REMAINING_FRACTION);
|
|
314
|
+
return input.results.filter((r) => r.isCorrect && r.timeToAnswerMs >= cutoff)
|
|
315
|
+
.length;
|
|
316
|
+
}
|
|
317
|
+
/** Length of the unbroken correct streak the run *ended* on (strong finish). */
|
|
318
|
+
function trailingStreak(input) {
|
|
319
|
+
let run = 0;
|
|
320
|
+
for (let i = input.results.length - 1; i >= 0; i--) {
|
|
321
|
+
if (input.results[i].isCorrect)
|
|
322
|
+
run++;
|
|
323
|
+
else
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
return run;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* True when the run spanned all three difficulty bands (each over its floor) and
|
|
330
|
+
* cleared the accuracy bar in every one — a polymath. Needs per-result
|
|
331
|
+
* difficulty; a single-band or `all`-without-tags run can't qualify.
|
|
332
|
+
*/
|
|
333
|
+
function isPolymath(input) {
|
|
334
|
+
const bands = new Map();
|
|
335
|
+
for (const r of input.results) {
|
|
336
|
+
if (!r.difficulty)
|
|
337
|
+
continue;
|
|
338
|
+
const e = bands.get(r.difficulty) ?? { correct: 0, total: 0 };
|
|
339
|
+
e.total++;
|
|
340
|
+
if (r.isCorrect)
|
|
341
|
+
e.correct++;
|
|
342
|
+
bands.set(r.difficulty, e);
|
|
343
|
+
}
|
|
344
|
+
if (bands.size < 3)
|
|
345
|
+
return false;
|
|
346
|
+
for (const band of ["easy", "medium", "hard"]) {
|
|
347
|
+
const e = bands.get(band);
|
|
348
|
+
if (!e || e.total < POLYMATH_MIN_PER_BAND)
|
|
349
|
+
return false;
|
|
350
|
+
if (e.correct / e.total < POLYMATH_BAND_ACCURACY)
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
/** Badges earned by a completed run (solo or multiplayer), in display order. */
|
|
356
|
+
export function computeBadges(input) {
|
|
357
|
+
const { context } = input;
|
|
358
|
+
const awards = [];
|
|
359
|
+
const accuracy = input.totalAnswered > 0 ? input.correctCount / input.totalAnswered : 0;
|
|
360
|
+
// Victor vs Podium — mutually exclusive (rank already says who won).
|
|
361
|
+
if (context.kind === "multiplayer") {
|
|
362
|
+
if (context.rank === 1 &&
|
|
363
|
+
context.score > 0 &&
|
|
364
|
+
context.heroCount >= VICTOR_MIN_HEROES &&
|
|
365
|
+
context.questionsServed >= RANK_BADGE_MIN_QUESTIONS) {
|
|
366
|
+
awards.push({
|
|
367
|
+
id: "victor",
|
|
368
|
+
detail: `first among ${context.heroCount}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
else if (context.rank <= PODIUM_MAX_RANK &&
|
|
372
|
+
context.score > 0 &&
|
|
373
|
+
context.heroCount >= PODIUM_MIN_HEROES &&
|
|
374
|
+
context.questionsServed >= RANK_BADGE_MIN_QUESTIONS) {
|
|
375
|
+
awards.push({ id: "podium", detail: `finished #${context.rank}` });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Flawless vs Sharpshooter — mutually exclusive (sharpshooter is "almost").
|
|
379
|
+
// Multiplayer holds a higher bar: acing every question of the *game* (present
|
|
380
|
+
// the whole way), not just every question in your own log — a late joiner
|
|
381
|
+
// going 3/3 in a 10-question game isn't flawless.
|
|
382
|
+
const flawlessTarget = context.kind === "multiplayer"
|
|
383
|
+
? context.totalQuestions
|
|
384
|
+
: input.totalAnswered;
|
|
385
|
+
if (input.totalAnswered > 0 &&
|
|
386
|
+
input.correctCount === input.totalAnswered &&
|
|
387
|
+
input.correctCount === flawlessTarget) {
|
|
388
|
+
awards.push({ id: "flawless", detail: "every answer correct" });
|
|
389
|
+
}
|
|
390
|
+
else if (input.totalAnswered >= SHARPSHOOTER_MIN_ANSWERED &&
|
|
391
|
+
accuracy >= SHARPSHOOTER_ACCURACY) {
|
|
392
|
+
awards.push({
|
|
393
|
+
id: "sharpshooter",
|
|
394
|
+
detail: `${percent(accuracy)}% accuracy`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Streak — only the highest tier earned.
|
|
398
|
+
if (input.maxStreak >= UNSTOPPABLE_STREAK) {
|
|
399
|
+
awards.push({ id: "unstoppable", detail: `${input.maxStreak} in a row` });
|
|
400
|
+
}
|
|
401
|
+
else if (input.maxStreak >= ON_A_ROLL_STREAK) {
|
|
402
|
+
awards.push({ id: "on-a-roll", detail: `${input.maxStreak} in a row` });
|
|
403
|
+
}
|
|
404
|
+
if (context.kind === "solo" &&
|
|
405
|
+
context.mode === "endless" &&
|
|
406
|
+
input.totalAnswered >= SURVIVOR_QUESTIONS) {
|
|
407
|
+
awards.push({
|
|
408
|
+
id: "survivor",
|
|
409
|
+
detail: `${input.totalAnswered} answered, ${input.correctCount} correct`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// Giant slayer: a strong showing on the hard setting specifically.
|
|
413
|
+
if (input.difficulty === "hard" &&
|
|
414
|
+
input.totalAnswered >= GIANT_SLAYER_MIN_ANSWERED &&
|
|
415
|
+
accuracy >= GIANT_SLAYER_ACCURACY) {
|
|
416
|
+
awards.push({
|
|
417
|
+
id: "giant-slayer",
|
|
418
|
+
detail: `${percent(accuracy)}% on hard`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// Pantheon supersedes Specialist — only the higher tier shows.
|
|
422
|
+
const aced = acedCategories(input);
|
|
423
|
+
if (aced.length >= PANTHEON_MIN_CATEGORIES) {
|
|
424
|
+
awards.push({ id: "pantheon", detail: `${aced.length} categories aced` });
|
|
425
|
+
}
|
|
426
|
+
else if (aced.length === 1) {
|
|
427
|
+
awards.push({ id: "specialist", detail: `aced ${humanize(aced[0])}` });
|
|
428
|
+
}
|
|
429
|
+
// Polymath: strong across every difficulty band in one trial (STATS-8).
|
|
430
|
+
if (isPolymath(input)) {
|
|
431
|
+
awards.push({ id: "polymath", detail: "strong across all difficulties" });
|
|
432
|
+
}
|
|
433
|
+
// Phoenix: rallied to a long streak after an earlier miss.
|
|
434
|
+
const comeback = comebackStreak(input);
|
|
435
|
+
if (comeback >= PHOENIX_STREAK) {
|
|
436
|
+
awards.push({ id: "phoenix", detail: `rallied to ${comeback} in a row` });
|
|
437
|
+
}
|
|
438
|
+
// Strong finish: closed the trial on a long unbroken streak (STATS-10).
|
|
439
|
+
const finish = trailingStreak(input);
|
|
440
|
+
if (finish >= STRONG_FINISH_STREAK) {
|
|
441
|
+
awards.push({ id: "strong-finish", detail: `${finish} to close it out` });
|
|
442
|
+
}
|
|
443
|
+
if (input.fasterThanMaxPercent !== null &&
|
|
444
|
+
input.fasterThanMaxPercent > FASTER_THAN_MOST_PERCENT) {
|
|
445
|
+
awards.push({
|
|
446
|
+
id: "faster-than-most",
|
|
447
|
+
detail: `beat ${input.fasterThanMaxPercent}% of solvers`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// Clutch: repeatedly landed answers in the final slice of the clock.
|
|
451
|
+
const clutch = clutchFinishes(input);
|
|
452
|
+
if (clutch >= CLUTCH_MIN) {
|
|
453
|
+
awards.push({ id: "clutch", detail: `${clutch} down to the wire` });
|
|
454
|
+
}
|
|
455
|
+
if (input.totalAnswered >= RATE_BADGE_MIN_ANSWERED &&
|
|
456
|
+
input.timeToAnswerAvgMs < QUICK_DRAW_AVG_MS) {
|
|
457
|
+
awards.push({
|
|
458
|
+
id: "quick-draw",
|
|
459
|
+
detail: `${(input.timeToAnswerAvgMs / 1000).toFixed(1)}s average`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const share = decisiveShare(input);
|
|
463
|
+
if (share !== null &&
|
|
464
|
+
share >= DECISIVE_THRESHOLD &&
|
|
465
|
+
input.totalAnswered >= RATE_BADGE_MIN_ANSWERED) {
|
|
466
|
+
const pct = percent(share);
|
|
467
|
+
awards.push({ id: "decisive", detail: `${pct}% before halfway` });
|
|
468
|
+
}
|
|
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
|
+
if (input.totalAnswered >= UNBROKEN_FOCUS_MIN_ANSWERED &&
|
|
475
|
+
input.tabAwayCount === 0) {
|
|
476
|
+
awards.push({ id: "unbroken-focus", detail: "never left the trial" });
|
|
477
|
+
}
|
|
478
|
+
// Mode tags (ADR 0018): only badges awardable in this mode survive. Applied
|
|
479
|
+
// before the meta-badge so master-of-none never counts a filtered honour.
|
|
480
|
+
const eligible = awards.filter((a) => BADGES[a.id].modes.includes(context.kind));
|
|
481
|
+
// Master of none (meta): broad play that still racked up honours — jack of
|
|
482
|
+
// all trades. Counts the badges earned *above*, so it never counts itself.
|
|
483
|
+
const distinctCategories = new Set(input.results.map((r) => r.categoryId))
|
|
484
|
+
.size;
|
|
485
|
+
if (distinctCategories > MASTER_OF_NONE_MIN_CATEGORIES &&
|
|
486
|
+
eligible.length > MASTER_OF_NONE_MIN_BADGES) {
|
|
487
|
+
eligible.push({
|
|
488
|
+
id: "master-of-none",
|
|
489
|
+
detail: `${distinctCategories} categories spanned`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
// Devotee: a consistency honour for returning on consecutive days (STATS-9).
|
|
493
|
+
// Cross-run, so it's gated on the player's day streak (signed-in only) rather
|
|
494
|
+
// than this run's play. Added after the meta so it never counts toward it.
|
|
495
|
+
if (context.kind === "solo" &&
|
|
496
|
+
input.consecutiveDays !== undefined &&
|
|
497
|
+
input.consecutiveDays >= DEVOTEE_DAYS) {
|
|
498
|
+
eligible.push({
|
|
499
|
+
id: "devotee",
|
|
500
|
+
detail: `${input.consecutiveDays} days running`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const rank = new Map(BADGE_ORDER.map((id, i) => [id, i]));
|
|
504
|
+
return (eligible
|
|
505
|
+
// The mode tags are the authoritative gate (ADR 0018): a final pass so
|
|
506
|
+
// badges appended after the meta-count filter can never bypass them.
|
|
507
|
+
.filter((a) => BADGES[a.id].modes.includes(context.kind))
|
|
508
|
+
.sort((a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0)));
|
|
509
|
+
}
|
|
510
|
+
export const RARITY_ORDER = ["common", "rare", "legendary", "exotic"];
|
|
511
|
+
export const RARITY_LABELS = {
|
|
512
|
+
common: "Common",
|
|
513
|
+
rare: "Rare",
|
|
514
|
+
legendary: "Legendary",
|
|
515
|
+
exotic: "Exotic",
|
|
516
|
+
};
|
|
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
|
+
const RARITY_RULES = {
|
|
529
|
+
victor: { base: 1, heroes: true, difficulty: true },
|
|
530
|
+
podium: { base: 0, heroes: true, difficulty: true },
|
|
531
|
+
flawless: { base: 1, questions: true, timer: "shorter", difficulty: true },
|
|
532
|
+
sharpshooter: {
|
|
533
|
+
base: 1,
|
|
534
|
+
questions: true,
|
|
535
|
+
timer: "shorter",
|
|
536
|
+
difficulty: true,
|
|
537
|
+
},
|
|
538
|
+
unstoppable: { base: 2, timer: "shorter", difficulty: true },
|
|
539
|
+
"on-a-roll": { base: 0, timer: "shorter", difficulty: true },
|
|
540
|
+
survivor: { base: 1, questions: true, difficulty: true },
|
|
541
|
+
"giant-slayer": {
|
|
542
|
+
base: 1,
|
|
543
|
+
questions: true,
|
|
544
|
+
timer: "shorter",
|
|
545
|
+
difficulty: true,
|
|
546
|
+
},
|
|
547
|
+
pantheon: { base: 2, questions: true, difficulty: true },
|
|
548
|
+
specialist: { base: 1, questions: true, difficulty: true },
|
|
549
|
+
polymath: { base: 2, questions: true, timer: "shorter" },
|
|
550
|
+
"strong-finish": {
|
|
551
|
+
base: 1,
|
|
552
|
+
questions: true,
|
|
553
|
+
timer: "shorter",
|
|
554
|
+
difficulty: true,
|
|
555
|
+
},
|
|
556
|
+
"sure-handed": { base: 1, questions: true, difficulty: true },
|
|
557
|
+
devotee: { base: 1 },
|
|
558
|
+
phoenix: { base: 1, difficulty: true },
|
|
559
|
+
"master-of-none": { base: 2, difficulty: true },
|
|
560
|
+
clutch: { base: 1, questions: true, difficulty: true },
|
|
561
|
+
"faster-than-most": { base: 1, timer: "shorter", difficulty: true },
|
|
562
|
+
"quick-draw": { base: 1, questions: true, timer: "longer", difficulty: true },
|
|
563
|
+
decisive: { base: 0, questions: true, timer: "shorter", difficulty: true },
|
|
564
|
+
"unbroken-focus": { base: 0, questions: true, difficulty: true },
|
|
565
|
+
};
|
|
566
|
+
/** More questions than the default 10 → harder to sustain (0–2). */
|
|
567
|
+
function questionLevel(count) {
|
|
568
|
+
if (count > 20)
|
|
569
|
+
return 2;
|
|
570
|
+
if (count > 10)
|
|
571
|
+
return 1;
|
|
572
|
+
return 0;
|
|
573
|
+
}
|
|
574
|
+
/** Shorter than the 30s baseline → harder (0–2). */
|
|
575
|
+
function shortTimerLevel(sec) {
|
|
576
|
+
if (sec <= 15)
|
|
577
|
+
return 2;
|
|
578
|
+
if (sec < 30)
|
|
579
|
+
return 1;
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Longer than baseline → a fast average is more impressive, but it's a soft
|
|
584
|
+
* signal (a generous clock is a common casual setting), so it's capped at +1.
|
|
585
|
+
* Reaching Exotic on Quick draw therefore needs a long clock *and* a harder run.
|
|
586
|
+
*/
|
|
587
|
+
function longTimerLevel(sec) {
|
|
588
|
+
return sec > 40 ? 1 : 0;
|
|
589
|
+
}
|
|
590
|
+
/** Harder difficulty settings raise rarity ("all" is the neutral default). */
|
|
591
|
+
function difficultyLevel(difficulty) {
|
|
592
|
+
if (difficulty === "hard")
|
|
593
|
+
return 2;
|
|
594
|
+
if (difficulty === "medium")
|
|
595
|
+
return 1;
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
/** A bigger field is harder to top: 8+ heroes bumps once, 12+ twice. */
|
|
599
|
+
function heroLevel(count) {
|
|
600
|
+
if (count === undefined)
|
|
601
|
+
return 0;
|
|
602
|
+
if (count >= 12)
|
|
603
|
+
return 2;
|
|
604
|
+
if (count >= 8)
|
|
605
|
+
return 1;
|
|
606
|
+
return 0;
|
|
607
|
+
}
|
|
608
|
+
/** The rarity tier a badge reaches when earned under the given run conditions. */
|
|
609
|
+
export function computeBadgeRarity(id, run) {
|
|
610
|
+
const rule = RARITY_RULES[id];
|
|
611
|
+
let bump = 0;
|
|
612
|
+
if (rule.questions)
|
|
613
|
+
bump += questionLevel(run.questionCount);
|
|
614
|
+
if (rule.timer === "shorter")
|
|
615
|
+
bump += shortTimerLevel(run.timerSec);
|
|
616
|
+
if (rule.timer === "longer")
|
|
617
|
+
bump += longTimerLevel(run.timerSec);
|
|
618
|
+
if (rule.difficulty)
|
|
619
|
+
bump += difficultyLevel(run.difficulty);
|
|
620
|
+
if (rule.heroes)
|
|
621
|
+
bump += heroLevel(run.heroCount);
|
|
622
|
+
bump = Math.min(bump, 2);
|
|
623
|
+
const tier = Math.min(rule.base + bump, RARITY_ORDER.length - 1);
|
|
624
|
+
return RARITY_ORDER[tier];
|
|
625
|
+
}
|
|
626
|
+
/** Parse a stored badge-id JSON array; malformed/missing/unknown ids read as none. */
|
|
627
|
+
export function parseBadgeIds(json) {
|
|
628
|
+
try {
|
|
629
|
+
const parsed = JSON.parse(json ?? "[]");
|
|
630
|
+
if (!Array.isArray(parsed))
|
|
631
|
+
return [];
|
|
632
|
+
return parsed.filter((id) => typeof id === "string" && id in BADGES);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return [];
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/** The higher of two rarity tiers. */
|
|
639
|
+
function betterRarity(a, b) {
|
|
640
|
+
return RARITY_ORDER.indexOf(a) > RARITY_ORDER.indexOf(b) ? a : b;
|
|
641
|
+
}
|
|
642
|
+
/** Fold one earned badge into a tally (count + best rarity). Mutates `tally`. */
|
|
643
|
+
export function addToBadgeTally(tally, id, rarity) {
|
|
644
|
+
const existing = tally[id];
|
|
645
|
+
if (!existing) {
|
|
646
|
+
tally[id] = { count: 1, bestRarity: rarity };
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
existing.count++;
|
|
650
|
+
existing.bestRarity = betterRarity(rarity, existing.bestRarity);
|
|
651
|
+
}
|
|
652
|
+
/** Merge tallies from different sources (e.g. solo + multiplayer) into one. */
|
|
653
|
+
export function mergeBadgeTallies(...tallies) {
|
|
654
|
+
const merged = {};
|
|
655
|
+
for (const tally of tallies) {
|
|
656
|
+
for (const [id, entry] of Object.entries(tally)) {
|
|
657
|
+
const existing = merged[id];
|
|
658
|
+
if (!existing) {
|
|
659
|
+
merged[id] = { ...entry };
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
existing.count += entry.count;
|
|
663
|
+
existing.bestRarity = betterRarity(entry.bestRarity, existing.bestRarity);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return merged;
|
|
667
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AuthUser, UserPower, UserRole } from "../types/auth.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What a Power is, not merely that it exists.
|
|
4
|
+
*
|
|
5
|
+
* - `gate` — stands between a request and a surface or its data. Granting one
|
|
6
|
+
* widens what the holder may do.
|
|
7
|
+
* - `display` — read only by the UI to decide what to render. Granting one
|
|
8
|
+
* widens nothing; revoking it refuses nobody.
|
|
9
|
+
*/
|
|
10
|
+
export type PowerShape = "gate" | "display";
|
|
11
|
+
/**
|
|
12
|
+
* The runtime list of `UserPower`, which is what `/admin/users` offers and what
|
|
13
|
+
* the request schema accepts. Derived from `POWERS`, so it cannot drift from
|
|
14
|
+
* the union. See `UserPower` for what each one reaches.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ALL_POWERS: UserPower[];
|
|
17
|
+
export declare const powerShape: (power: UserPower) => PowerShape;
|
|
18
|
+
/** One line for `/admin/users` to print under the toggle. */
|
|
19
|
+
export declare const powerDescription: (power: UserPower) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Rank per role, for `hasRole`. `anonymous` and `display` share rank 0 —
|
|
22
|
+
* neither is an account, so every "is this signed in" gate compares against
|
|
23
|
+
* `member` rather than naming a role.
|
|
24
|
+
*/
|
|
25
|
+
export declare const ROLE_HIERARCHY: Record<UserRole, number>;
|
|
26
|
+
export declare const ANON_USER: AuthUser;
|