AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 3.3 kB View raw
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}