AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
1import { MAX_GUESSES, type Lang } from '../config.js';
2import type { GameResult } from './types.js';
3
4/** A bucket of the winning-guess-count histogram. */
5export interface GuessBucket {
6 guesses: number;
7 count: number;
8}
9
10/** Per-language stats block (mirrors lexicon bzh.herve.atmot.stats#langStats). */
11export interface LangStats {
12 lang: string;
13 currentStreak: number;
14 maxStreak: number;
15 gamesPlayed: number;
16 gamesWon: number;
17 lastPuzzleNumber: number;
18 guessDistribution: GuessBucket[];
19}
20
21/** The full stats record (mirrors lexicon bzh.herve.atmot.stats main). */
22export interface StatsRecord {
23 $type?: 'bzh.herve.atmot.stats';
24 byLang: LangStats[];
25 updatedAt: string;
26}
27
28function emptyLangStats(lang: string): LangStats {
29 return {
30 lang,
31 currentStreak: 0,
32 maxStreak: 0,
33 gamesPlayed: 0,
34 gamesWon: 0,
35 lastPuzzleNumber: 0,
36 guessDistribution: [],
37 };
38}
39
40function bumpDistribution(dist: GuessBucket[], guesses: number): GuessBucket[] {
41 const out = dist.map((b) => ({ ...b }));
42 const existing = out.find((b) => b.guesses === guesses);
43 if (existing) existing.count += 1;
44 else out.push({ guesses, count: 1 });
45 out.sort((a, b) => a.guesses - b.guesses);
46 return out;
47}
48
49/**
50 * Pure stats transition: given the previous stats record (or null on first play)
51 * and a freshly completed game, return the next stats record. Clock value
52 * (`updatedAt`) is passed in so this stays deterministic and unit-testable.
53 *
54 * Streak rules: a win on the next consecutive puzzle extends the streak; a win
55 * after a gap restarts it at 1; a loss resets it to 0. Replaying an older puzzle
56 * updates totals/distribution but never disturbs the streak or lastPuzzleNumber.
57 *
58 * The caller is responsible for not invoking this twice for the same puzzle
59 * (results are write-once, which provides that guard).
60 */
61export function computeNextStats(
62 prev: StatsRecord | null,
63 result: GameResult,
64 updatedAt: string,
65): StatsRecord {
66 const byLang = (prev?.byLang ?? []).map((b) => ({ ...b, guessDistribution: [...b.guessDistribution] }));
67 let bucket = byLang.find((b) => b.lang === result.lang);
68 if (!bucket) {
69 bucket = emptyLangStats(result.lang);
70 byLang.push(bucket);
71 }
72
73 bucket.gamesPlayed += 1;
74 if (result.solved) {
75 bucket.gamesWon += 1;
76 const guesses = result.guessCount ?? result.rows.length;
77 if (guesses >= 1 && guesses <= MAX_GUESSES) {
78 bucket.guessDistribution = bumpDistribution(bucket.guessDistribution, guesses);
79 }
80 }
81
82 // Streak only advances for forward progress (newest puzzle).
83 if (result.puzzleNumber > bucket.lastPuzzleNumber) {
84 const consecutive =
85 bucket.lastPuzzleNumber > 0 && result.puzzleNumber === bucket.lastPuzzleNumber + 1;
86 if (result.solved) bucket.currentStreak = consecutive ? bucket.currentStreak + 1 : 1;
87 else bucket.currentStreak = 0;
88 bucket.lastPuzzleNumber = result.puzzleNumber;
89 }
90
91 bucket.maxStreak = Math.max(bucket.maxStreak, bucket.currentStreak);
92
93 byLang.sort((a, b) => a.lang.localeCompare(b.lang));
94 return { $type: 'bzh.herve.atmot.stats', byLang, updatedAt };
95}
96
97/** Convenience accessor. */
98export function statsForLang(stats: StatsRecord | null, lang: Lang): LangStats | undefined {
99 return stats?.byLang.find((b) => b.lang === lang);
100}