@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 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
- * Best "faster than most" reading this run: the highest % of prior correct
56
- * solvers any single answer beat on time. Null when no question had enough
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
- answerChanges?: number;
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
- "decisive",
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 quiz.",
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 among the top three in a multiplayer quiz.",
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 trial without a single miss.",
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-difficulty trial with commanding accuracy.",
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 trial.",
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 long streak after an early stumble.",
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 of the clock.",
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 trial with near-perfect accuracy.",
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: "Answered well across easy, medium and hard in one trial.",
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 trial on a long unbroken streak.",
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: "Committed every answer without second-guessing.",
156
+ description: "Answered four in five correctly before the clock's halfway mark.",
147
157
  icon: "check",
148
- modes: SOLO_ONLY,
158
+ modes: CROSS_MODE,
149
159
  },
150
160
  devotee: {
151
161
  id: "devotee",
152
162
  label: "Devotee",
153
- description: "Returned to the oracle on consecutive days.",
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: "Master of none",
160
- description: "Earned a spread of honours across many categories at once.",
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: "Faster than most",
167
- description: "Beat most other players' time on a question.",
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: "Answered with a lightning-fast average time.",
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: "Never left the trial.",
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
- /** A single answer beating more than this % of prior solvers earns faster-than-most. */
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 "master of none" meta-badge. */
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 "master of none". */
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" (never changed an answer) is an achievement. */
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.fasterThanMaxPercent !== null &&
444
- input.fasterThanMaxPercent > FASTER_THAN_MOST_PERCENT) {
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: `beat ${input.fasterThanMaxPercent}% of solvers`,
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 (share !== null &&
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
- // Master of none (meta): broad play that still racked up honours — jack of
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": { base: 1, questions: true, difficulty: true },
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, timer: "shorter", difficulty: true },
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.1.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
- "@orakl/protocol": "0.1.0",
21
- "date-fns": "^4.4.0"
20
+ "date-fns": "^4.4.0",
21
+ "@orakl/protocol": "0.2.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^26.4.1",