@orakl/shared 0.1.0 → 0.3.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 +51 -16
- package/dist/badges.js +375 -49
- package/package.json +3 -3
package/dist/badges.d.ts
CHANGED
|
@@ -32,6 +32,20 @@ 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
|
+
* and whether their score was strictly above everyone else's; null for
|
|
42
|
+
* rounds before they held a hero slot (Ascendant, Undisputed).
|
|
43
|
+
*/
|
|
44
|
+
rankHistory?: Array<{
|
|
45
|
+
rank: number;
|
|
46
|
+
field: number;
|
|
47
|
+
lead: boolean;
|
|
48
|
+
} | null>;
|
|
35
49
|
};
|
|
36
50
|
export interface BadgeInput {
|
|
37
51
|
context: BadgeContext;
|
|
@@ -52,22 +66,30 @@ export interface BadgeInput {
|
|
|
52
66
|
/** Mean time-to-answer (ms) across the run. */
|
|
53
67
|
timeToAnswerAvgMs: number;
|
|
54
68
|
/**
|
|
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.
|
|
69
|
+
* Correct answers this run that each beat more than 75% of prior correct
|
|
70
|
+
* solvers on time (the player's own attempts excluded). Drives Wing-footed.
|
|
64
71
|
*/
|
|
65
|
-
|
|
72
|
+
fastAnswerCount: number;
|
|
66
73
|
/**
|
|
67
74
|
* The player's consecutive-days-played streak as of this run (signed-in only).
|
|
68
75
|
* Drives the Devotee badge; undefined for guests / when not computed.
|
|
69
76
|
*/
|
|
70
77
|
consecutiveDays?: number;
|
|
78
|
+
/** This run's total score. Drives New heights; undefined when not computed. */
|
|
79
|
+
runScore?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Best score across the player's prior completed runs in this run's exact
|
|
82
|
+
* trial bucket (question count, timer, difficulty; normal mode); null when
|
|
83
|
+
* none. Drives New heights; undefined for guests / lookup failure.
|
|
84
|
+
*/
|
|
85
|
+
bucketBest?: number | null;
|
|
86
|
+
/** Count of those prior runs; New heights needs at least 3. */
|
|
87
|
+
bucketPriorRuns?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Distinct categories the player answered correctly across prior runs.
|
|
90
|
+
* Drives Far horizons; undefined for guests / lookup failure.
|
|
91
|
+
*/
|
|
92
|
+
priorCorrectCategories?: string[];
|
|
71
93
|
results: Array<{
|
|
72
94
|
timeToAnswerMs: number;
|
|
73
95
|
categoryId: string;
|
|
@@ -76,8 +98,27 @@ export interface BadgeInput {
|
|
|
76
98
|
difficulty?: "easy" | "medium" | "hard";
|
|
77
99
|
}>;
|
|
78
100
|
}
|
|
101
|
+
/** An answer beating more than this % of prior solvers counts toward Wing-footed. */
|
|
102
|
+
export declare const FASTER_THAN_MOST_PERCENT = 75;
|
|
79
103
|
/** Served-questions floor for the rank badges (victor, podium) and lifetime best-rank — below this a rank isn't a claim. */
|
|
80
104
|
export declare const RANK_BADGE_MIN_QUESTIONS = 6;
|
|
105
|
+
/** One player's row for one round of a multiplayer game's answer log. */
|
|
106
|
+
export interface FieldLogEntry {
|
|
107
|
+
questionIndex: number;
|
|
108
|
+
isCorrect: boolean;
|
|
109
|
+
/** True when the player chose an answer; false for a miss-filled round. */
|
|
110
|
+
submitted: boolean;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Field-relative round counts for `playerId` from every tenured player's log.
|
|
114
|
+
* The field for a round is the other players' submitted answers; a miss-filled
|
|
115
|
+
* row (a leaver, or a timeout) is not a wrong answer and is left out. Round
|
|
116
|
+
* tallies are cached per `logs` map, so `logs` must not change between calls.
|
|
117
|
+
*/
|
|
118
|
+
export declare function fieldRoundStats(playerId: string, logs: ReadonlyMap<string, readonly FieldLogEntry[]>): {
|
|
119
|
+
fieldRoundsDefied: number;
|
|
120
|
+
soleCorrectRounds: number;
|
|
121
|
+
};
|
|
81
122
|
/**
|
|
82
123
|
* Raw share (0–1) of answers committed before the clock's halfway mark — quick
|
|
83
124
|
* answers read as decisive (implicit confidence from timing). Null when not
|
|
@@ -103,12 +144,6 @@ export declare function computeBadges(input: BadgeInput): BadgeAward[];
|
|
|
103
144
|
export type Rarity = "common" | "rare" | "legendary" | "exotic";
|
|
104
145
|
export declare const RARITY_ORDER: Rarity[];
|
|
105
146
|
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
147
|
/** The rarity tier a badge reaches when earned under the given run conditions. */
|
|
113
148
|
export declare function computeBadgeRarity(id: BadgeId, run: BadgeRunParams): Rarity;
|
|
114
149
|
/** 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,88 @@ 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
|
+
/** Per-round submitted/correct totals across every log (first row per round). */
|
|
375
|
+
function tallyRounds(logs) {
|
|
376
|
+
const tallies = new Map();
|
|
377
|
+
for (const log of logs.values()) {
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
for (const e of log) {
|
|
380
|
+
if (seen.has(e.questionIndex))
|
|
381
|
+
continue;
|
|
382
|
+
seen.add(e.questionIndex);
|
|
383
|
+
if (!e.submitted)
|
|
384
|
+
continue;
|
|
385
|
+
let t = tallies.get(e.questionIndex);
|
|
386
|
+
if (!t) {
|
|
387
|
+
t = { submitted: 0, correct: 0 };
|
|
388
|
+
tallies.set(e.questionIndex, t);
|
|
389
|
+
}
|
|
390
|
+
t.submitted++;
|
|
391
|
+
if (e.isCorrect)
|
|
392
|
+
t.correct++;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return tallies;
|
|
396
|
+
}
|
|
397
|
+
// Keyed by the logs map so a final-scores pass tallies once for all players.
|
|
398
|
+
const tallyCache = new WeakMap();
|
|
399
|
+
/**
|
|
400
|
+
* Field-relative round counts for `playerId` from every tenured player's log.
|
|
401
|
+
* The field for a round is the other players' submitted answers; a miss-filled
|
|
402
|
+
* row (a leaver, or a timeout) is not a wrong answer and is left out. Round
|
|
403
|
+
* tallies are cached per `logs` map, so `logs` must not change between calls.
|
|
404
|
+
*/
|
|
405
|
+
export function fieldRoundStats(playerId, logs) {
|
|
406
|
+
let tallies = tallyCache.get(logs);
|
|
407
|
+
if (!tallies) {
|
|
408
|
+
tallies = tallyRounds(logs);
|
|
409
|
+
tallyCache.set(logs, tallies);
|
|
410
|
+
}
|
|
411
|
+
let fieldRoundsDefied = 0;
|
|
412
|
+
let soleCorrectRounds = 0;
|
|
413
|
+
const own = logs.get(playerId) ?? [];
|
|
414
|
+
const firstOwn = new Map();
|
|
415
|
+
for (const e of own) {
|
|
416
|
+
if (!firstOwn.has(e.questionIndex))
|
|
417
|
+
firstOwn.set(e.questionIndex, e);
|
|
418
|
+
}
|
|
419
|
+
for (const entry of own) {
|
|
420
|
+
if (!entry.isCorrect)
|
|
421
|
+
continue;
|
|
422
|
+
const t = tallies.get(entry.questionIndex);
|
|
423
|
+
let others = t?.submitted ?? 0;
|
|
424
|
+
let othersCorrect = t?.correct ?? 0;
|
|
425
|
+
const mine = firstOwn.get(entry.questionIndex);
|
|
426
|
+
if (mine?.submitted) {
|
|
427
|
+
others--;
|
|
428
|
+
if (mine.isCorrect)
|
|
429
|
+
othersCorrect--;
|
|
430
|
+
}
|
|
431
|
+
const wrong = others - othersCorrect;
|
|
432
|
+
if (others >= CASSANDRA_MIN_OTHERS &&
|
|
433
|
+
wrong / others >= CASSANDRA_WRONG_SHARE) {
|
|
434
|
+
fieldRoundsDefied++;
|
|
435
|
+
}
|
|
436
|
+
if (othersCorrect === 0 && others >= LONE_TORCH_MIN_SUBMITTERS) {
|
|
437
|
+
soleCorrectRounds++;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return { fieldRoundsDefied, soleCorrectRounds };
|
|
441
|
+
}
|
|
252
442
|
function humanize(categoryId) {
|
|
253
443
|
return categoryId.replace(/-/g, " ");
|
|
254
444
|
}
|
|
@@ -325,6 +515,20 @@ function trailingStreak(input) {
|
|
|
325
515
|
}
|
|
326
516
|
return run;
|
|
327
517
|
}
|
|
518
|
+
/** True when any two consecutive answers were both misses. */
|
|
519
|
+
function hasConsecutiveMisses(input) {
|
|
520
|
+
for (let i = 1; i < input.results.length; i++) {
|
|
521
|
+
if (!input.results[i].isCorrect && !input.results[i - 1].isCorrect) {
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
/** True when the run opened with `n` correct answers in a row. */
|
|
528
|
+
function openedWithStreak(input, n) {
|
|
529
|
+
return (input.results.length >= n &&
|
|
530
|
+
input.results.slice(0, n).every((r) => r.isCorrect));
|
|
531
|
+
}
|
|
328
532
|
/**
|
|
329
533
|
* True when the run spanned all three difficulty bands (each over its floor) and
|
|
330
534
|
* cleared the accuracy bar in every one — a polymath. Needs per-result
|
|
@@ -440,11 +644,11 @@ export function computeBadges(input) {
|
|
|
440
644
|
if (finish >= STRONG_FINISH_STREAK) {
|
|
441
645
|
awards.push({ id: "strong-finish", detail: `${finish} to close it out` });
|
|
442
646
|
}
|
|
443
|
-
if (input.
|
|
444
|
-
input.
|
|
647
|
+
if (input.totalAnswered >= WING_FOOTED_MIN_ANSWERED &&
|
|
648
|
+
input.fastAnswerCount >= WING_FOOTED_FAST_ANSWERS) {
|
|
445
649
|
awards.push({
|
|
446
650
|
id: "faster-than-most",
|
|
447
|
-
detail: `
|
|
651
|
+
detail: `outran most on ${input.fastAnswerCount} questions`,
|
|
448
652
|
});
|
|
449
653
|
}
|
|
450
654
|
// Clutch: repeatedly landed answers in the final slice of the clock.
|
|
@@ -459,26 +663,73 @@ export function computeBadges(input) {
|
|
|
459
663
|
detail: `${(input.timeToAnswerAvgMs / 1000).toFixed(1)}s average`,
|
|
460
664
|
});
|
|
461
665
|
}
|
|
666
|
+
// Sure-handed: four in five answers correct before halfway. Outranks
|
|
667
|
+
// decisive, which it implies.
|
|
668
|
+
const sureHandedShare = input.timerDurationMs > 0 && input.totalAnswered >= SURE_HANDED_MIN_ANSWERED
|
|
669
|
+
? input.results.filter((r) => r.isCorrect && r.timeToAnswerMs <= input.timerDurationMs / 2).length / input.totalAnswered
|
|
670
|
+
: 0;
|
|
671
|
+
const sureHanded = sureHandedShare >= SURE_HANDED_SHARE;
|
|
672
|
+
if (sureHanded) {
|
|
673
|
+
awards.push({
|
|
674
|
+
id: "sure-handed",
|
|
675
|
+
detail: `${percent(sureHandedShare)}% right before halfway`,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
462
678
|
const share = decisiveShare(input);
|
|
463
|
-
if (
|
|
679
|
+
if (!sureHanded &&
|
|
680
|
+
share !== null &&
|
|
464
681
|
share >= DECISIVE_THRESHOLD &&
|
|
465
682
|
input.totalAnswered >= RATE_BADGE_MIN_ANSWERED) {
|
|
466
683
|
const pct = percent(share);
|
|
467
684
|
awards.push({ id: "decisive", detail: `${pct}% before halfway` });
|
|
468
685
|
}
|
|
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
686
|
if (input.totalAnswered >= UNBROKEN_FOCUS_MIN_ANSWERED &&
|
|
475
687
|
input.tabAwayCount === 0) {
|
|
476
688
|
awards.push({ id: "unbroken-focus", detail: "never left the trial" });
|
|
477
689
|
}
|
|
690
|
+
// Herculean: every hard question right in a mixed-difficulty run.
|
|
691
|
+
const hardResults = input.results.filter((r) => r.difficulty === "hard");
|
|
692
|
+
if (input.difficulty === "all" &&
|
|
693
|
+
hardResults.length >= HERCULEAN_MIN_HARD &&
|
|
694
|
+
hardResults.every((r) => r.isCorrect)) {
|
|
695
|
+
awards.push({
|
|
696
|
+
id: "herculean",
|
|
697
|
+
detail: `${hardResults.length} hard, all correct`,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
// Undaunted: a long run whose misses never came back-to-back.
|
|
701
|
+
const misses = input.totalAnswered - input.correctCount;
|
|
702
|
+
if (input.totalAnswered >= UNDAUNTED_MIN_ANSWERED &&
|
|
703
|
+
misses >= UNDAUNTED_MIN_MISSES &&
|
|
704
|
+
accuracy >= UNDAUNTED_ACCURACY &&
|
|
705
|
+
!hasConsecutiveMisses(input)) {
|
|
706
|
+
awards.push({
|
|
707
|
+
id: "undaunted",
|
|
708
|
+
detail: `${misses} misses, never two in a row`,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
// Measured: high accuracy while taking time on most answers.
|
|
712
|
+
if (input.totalAnswered >= MEASURED_MIN_ANSWERED &&
|
|
713
|
+
accuracy >= MEASURED_ACCURACY &&
|
|
714
|
+
share !== null &&
|
|
715
|
+
share <= MEASURED_MAX_DECISIVE_SHARE) {
|
|
716
|
+
awards.push({
|
|
717
|
+
id: "measured",
|
|
718
|
+
detail: `${percent(accuracy)}% accuracy, unhurried`,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
// Strong start: opened the run on five correct in a row.
|
|
722
|
+
if (input.totalAnswered >= STRONG_START_MIN_ANSWERED &&
|
|
723
|
+
openedWithStreak(input, STRONG_START_STREAK)) {
|
|
724
|
+
awards.push({
|
|
725
|
+
id: "strong-start",
|
|
726
|
+
detail: `${STRONG_START_STREAK} to open`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
478
729
|
// Mode tags (ADR 0018): only badges awardable in this mode survive. Applied
|
|
479
730
|
// before the meta-badge so master-of-none never counts a filtered honour.
|
|
480
731
|
const eligible = awards.filter((a) => BADGES[a.id].modes.includes(context.kind));
|
|
481
|
-
//
|
|
732
|
+
// Many-sided (meta): broad play that still racked up honours — jack of
|
|
482
733
|
// all trades. Counts the badges earned *above*, so it never counts itself.
|
|
483
734
|
const distinctCategories = new Set(input.results.map((r) => r.categoryId))
|
|
484
735
|
.size;
|
|
@@ -489,6 +740,48 @@ export function computeBadges(input) {
|
|
|
489
740
|
detail: `${distinctCategories} categories spanned`,
|
|
490
741
|
});
|
|
491
742
|
}
|
|
743
|
+
// Field badges (multiplayer): derived from the game's answer log and per-round
|
|
744
|
+
// rank snapshots. Added after the meta so they never count toward it.
|
|
745
|
+
if (context.kind === "multiplayer") {
|
|
746
|
+
const victorGates = context.score > 0 &&
|
|
747
|
+
context.heroCount >= VICTOR_MIN_HEROES &&
|
|
748
|
+
context.questionsServed >= RANK_BADGE_MIN_QUESTIONS;
|
|
749
|
+
const podiumGates = context.score > 0 &&
|
|
750
|
+
context.heroCount >= PODIUM_MIN_HEROES &&
|
|
751
|
+
context.questionsServed >= RANK_BADGE_MIN_QUESTIONS;
|
|
752
|
+
const history = context.rankHistory ?? [];
|
|
753
|
+
const defied = context.fieldRoundsDefied ?? 0;
|
|
754
|
+
const cassandra = context.heroCount >= CASSANDRA_MIN_HEROES &&
|
|
755
|
+
defied >= CASSANDRA_MIN_ROUNDS;
|
|
756
|
+
if (cassandra) {
|
|
757
|
+
eligible.push({
|
|
758
|
+
id: "cassandra",
|
|
759
|
+
detail: `right against the field ${defied}×`,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
if (!cassandra &&
|
|
763
|
+
context.heroCount >= LONE_TORCH_MIN_HEROES &&
|
|
764
|
+
(context.soleCorrectRounds ?? 0) >= 1) {
|
|
765
|
+
eligible.push({ id: "lone-torch", detail: "only one right in a round" });
|
|
766
|
+
}
|
|
767
|
+
const mid = history[Math.floor((history.length - 1) / 2)];
|
|
768
|
+
if (podiumGates &&
|
|
769
|
+
context.rank <= PODIUM_MAX_RANK &&
|
|
770
|
+
mid &&
|
|
771
|
+
mid.rank > Math.ceil(mid.field / 2) &&
|
|
772
|
+
mid.rank - context.rank >= ASCENDANT_MIN_CLIMB) {
|
|
773
|
+
eligible.push({
|
|
774
|
+
id: "ascendant",
|
|
775
|
+
detail: `#${mid.rank} at halfway, finished #${context.rank}`,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
if (victorGates &&
|
|
779
|
+
context.rank === 1 &&
|
|
780
|
+
history.length >= 2 &&
|
|
781
|
+
history.slice(1).every((h) => h?.rank === 1 && h.lead)) {
|
|
782
|
+
eligible.push({ id: "undisputed", detail: "first from round two" });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
492
785
|
// Devotee: a consistency honour for returning on consecutive days (STATS-9).
|
|
493
786
|
// Cross-run, so it's gated on the player's day streak (signed-in only) rather
|
|
494
787
|
// than this run's play. Added after the meta so it never counts toward it.
|
|
@@ -500,6 +793,35 @@ export function computeBadges(input) {
|
|
|
500
793
|
detail: `${input.consecutiveDays} days running`,
|
|
501
794
|
});
|
|
502
795
|
}
|
|
796
|
+
// New heights: a personal best in the same trial bucket, after enough prior
|
|
797
|
+
// runs to make it a claim. Cross-run, so appended after the meta-count.
|
|
798
|
+
if (context.kind === "solo" &&
|
|
799
|
+
context.mode === "normal" &&
|
|
800
|
+
input.runScore !== undefined &&
|
|
801
|
+
input.bucketBest != null &&
|
|
802
|
+
(input.bucketPriorRuns ?? 0) >= NEW_HEIGHTS_MIN_PRIOR_RUNS &&
|
|
803
|
+
input.runScore > input.bucketBest) {
|
|
804
|
+
eligible.push({
|
|
805
|
+
id: "new-heights",
|
|
806
|
+
detail: `${input.runScore} beat ${input.bucketBest}`,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
// Far horizons: awarded once, on the run that lifts the player's distinct
|
|
810
|
+
// correctly-answered categories to the threshold.
|
|
811
|
+
if (context.kind === "solo" && input.priorCorrectCategories !== undefined) {
|
|
812
|
+
const prior = new Set(input.priorCorrectCategories);
|
|
813
|
+
const total = new Set(prior);
|
|
814
|
+
for (const r of input.results)
|
|
815
|
+
if (r.isCorrect)
|
|
816
|
+
total.add(r.categoryId);
|
|
817
|
+
if (prior.size < FAR_HORIZONS_CATEGORIES &&
|
|
818
|
+
total.size >= FAR_HORIZONS_CATEGORIES) {
|
|
819
|
+
eligible.push({
|
|
820
|
+
id: "far-horizons",
|
|
821
|
+
detail: `${total.size} categories mastered`,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
503
825
|
const rank = new Map(BADGE_ORDER.map((id, i) => [id, i]));
|
|
504
826
|
return (eligible
|
|
505
827
|
// The mode tags are the authoritative gate (ADR 0018): a final pass so
|
|
@@ -514,17 +836,6 @@ export const RARITY_LABELS = {
|
|
|
514
836
|
legendary: "Legendary",
|
|
515
837
|
exotic: "Exotic",
|
|
516
838
|
};
|
|
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
839
|
const RARITY_RULES = {
|
|
529
840
|
victor: { base: 1, heroes: true, difficulty: true },
|
|
530
841
|
podium: { base: 0, heroes: true, difficulty: true },
|
|
@@ -553,15 +864,30 @@ const RARITY_RULES = {
|
|
|
553
864
|
timer: "shorter",
|
|
554
865
|
difficulty: true,
|
|
555
866
|
},
|
|
556
|
-
"sure-handed": {
|
|
867
|
+
"sure-handed": {
|
|
868
|
+
base: 1,
|
|
869
|
+
questions: true,
|
|
870
|
+
timer: "shorter",
|
|
871
|
+
difficulty: true,
|
|
872
|
+
},
|
|
557
873
|
devotee: { base: 1 },
|
|
558
874
|
phoenix: { base: 1, difficulty: true },
|
|
559
875
|
"master-of-none": { base: 2, difficulty: true },
|
|
560
876
|
clutch: { base: 1, questions: true, difficulty: true },
|
|
561
|
-
"faster-than-most": { base: 1,
|
|
877
|
+
"faster-than-most": { base: 1, difficulty: true },
|
|
562
878
|
"quick-draw": { base: 1, questions: true, timer: "longer", difficulty: true },
|
|
563
879
|
decisive: { base: 0, questions: true, timer: "shorter", difficulty: true },
|
|
564
880
|
"unbroken-focus": { base: 0, questions: true, difficulty: true },
|
|
881
|
+
herculean: { base: 2, difficulty: true },
|
|
882
|
+
undaunted: { base: 0, questions: true, difficulty: true },
|
|
883
|
+
measured: { base: 0, questions: true, difficulty: true },
|
|
884
|
+
"strong-start": { base: 0, timer: "shorter", difficulty: true },
|
|
885
|
+
"new-heights": { base: 1, difficulty: true },
|
|
886
|
+
"far-horizons": { base: 1 },
|
|
887
|
+
cassandra: { base: 1, heroes: true, difficulty: true },
|
|
888
|
+
"lone-torch": { base: 0, heroes: true, difficulty: true },
|
|
889
|
+
ascendant: { base: 1, heroes: true },
|
|
890
|
+
undisputed: { base: 2, heroes: true, difficulty: true },
|
|
565
891
|
};
|
|
566
892
|
/** More questions than the default 10 → harder to sustain (0–2). */
|
|
567
893
|
function questionLevel(count) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orakl/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|