diff --git a/scratch/run-tests.mjs b/scratch/run-tests.mjs index fbaab3b..a3571e1 100644 --- a/scratch/run-tests.mjs +++ b/scratch/run-tests.mjs @@ -5,6 +5,8 @@ import path from 'node:path'; const testDir = path.resolve(process.cwd(), 'tests'); const nodeBin = process.execPath; const jitiCli = path.resolve(process.cwd(), 'node_modules/jiti/lib/jiti-cli.mjs'); +const vitestCli = path.resolve(process.cwd(), 'node_modules/vitest/vitest.mjs'); +const vitestTests = new Set(['elo-rating.test.ts']); // Authoritative offline unit, domain, security, and integration test suites const liveDbTests = new Set([ @@ -44,7 +46,10 @@ for (const file of files) { console.log(`------------------------------------------------------------`); const start = Date.now(); - const res = spawnSync(nodeBin, [jitiCli, filePath], { + const args = vitestTests.has(file) + ? [vitestCli, 'run', filePath] + : [jitiCli, filePath]; + const res = spawnSync(nodeBin, args, { cwd: process.cwd(), stdio: 'inherit', }); diff --git a/src/lib/gamification/elo-rating.ts b/src/lib/gamification/elo-rating.ts new file mode 100644 index 0000000..e000d5c --- /dev/null +++ b/src/lib/gamification/elo-rating.ts @@ -0,0 +1,92 @@ +/** Match score: 1 for a win, 0.5 for a draw, and 0 for a loss. */ +export type MatchOutcome = 1 | 0.5 | 0; + +/** Rating bands used by accountability circle leaderboards. */ +export type MemberRankTier = 'novice' | 'apprentice' | 'adept' | 'master' | 'grandmaster'; + +/** Member statistics before a match; new members start at rating 1200. */ +export interface EloMemberProfile { + rating: number; + matchesPlayed: number; + winStreak: number; +} + +/** Updated ratings and actual deltas after applying the rating floor. */ +export interface EloMatchResult { + playerNewRating: number; + opponentNewRating: number; + playerRatingDelta: number; + opponentRatingDelta: number; + playerExpectedScore: number; + opponentExpectedScore: number; + playerNewTier: MemberRankTier; +} + +/** Optional tuning for accountability circle matches. */ +export interface EloOptions { + /** Defaults to 32. */ + baseKFactor?: number; + /** Defaults to 10; provisional members use 1.5 times the base K. */ + placementMatchesCount?: number; + /** Defaults to true; a winner with a pre-match streak >= 3 gets +2. */ + enableStreakBonus?: boolean; +} + +/** Calculate the standard Elo expected score, rounded to four decimals. */ +export function calculateExpectedScore(playerRating: number, opponentRating: number): number { + return Math.round((1 / (1 + 10 ** ((opponentRating - playerRating) / 400))) * 10000) / 10000; +} + +/** Placement volatility takes precedence over elite (rating >= 2000) stability. */ +export function determineKFactor(profile: EloMemberProfile, baseK = 32, placementCount = 10): number { + if (profile.matchesPlayed < placementCount) return baseK * 1.5; + if (profile.rating >= 2000) return baseK * 0.5; + return baseK; +} + +/** Classify a rating using inclusive lower boundaries for each rank. */ +export function getMemberTier(rating: number): MemberRankTier { + if (rating < 1000) return 'novice'; + if (rating < 1400) return 'apprentice'; + if (rating < 1800) return 'adept'; + if (rating < 2200) return 'master'; + return 'grandmaster'; +} + +/** + * Resolve a match without mutating either pre-match profile. + * Each member uses their own K-factor. Only the winner can earn the additive + * streak bonus, so adjustments need not sum to zero. Ratings cannot drop below 100. + */ +export function calculateEloMatch( + player: EloMemberProfile, + opponent: EloMemberProfile, + outcome: MatchOutcome, + options: EloOptions = {}, +): EloMatchResult { + const { baseKFactor = 32, placementMatchesCount = 10, enableStreakBonus = true } = options; + const playerExpectedScore = calculateExpectedScore(player.rating, opponent.rating); + const opponentExpectedScore = calculateExpectedScore(opponent.rating, player.rating); + const playerK = determineKFactor(player, baseKFactor, placementMatchesCount); + const opponentK = determineKFactor(opponent, baseKFactor, placementMatchesCount); + const playerBonus = enableStreakBonus && outcome === 1 && player.winStreak >= 3 ? 2 : 0; + const opponentBonus = enableStreakBonus && outcome === 0 && opponent.winStreak >= 3 ? 2 : 0; + const playerNewRating = Math.max( + 100, + player.rating + Math.round(playerK * (outcome - playerExpectedScore)) + playerBonus, + ); + const opponentNewRating = Math.max( + 100, + opponent.rating + Math.round(opponentK * (1 - outcome - opponentExpectedScore)) + opponentBonus, + ); + + return { + playerNewRating, + opponentNewRating, + playerRatingDelta: playerNewRating - player.rating, + opponentRatingDelta: opponentNewRating - opponent.rating, + playerExpectedScore, + opponentExpectedScore, + playerNewTier: getMemberTier(playerNewRating), + }; +} diff --git a/tests/elo-rating.test.ts b/tests/elo-rating.test.ts new file mode 100644 index 0000000..a959e3c --- /dev/null +++ b/tests/elo-rating.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + calculateEloMatch, + calculateExpectedScore, + determineKFactor, + getMemberTier, + type EloMemberProfile, +} from '../src/lib/gamification/elo-rating'; + +const member = (rating = 1200, matchesPlayed = 10, winStreak = 0): EloMemberProfile => ({ + rating, matchesPlayed, winStreak, +}); + +describe('Elo expected scores', () => { + it('returns exactly 0.5 for equals and rounds unequal scores to four decimals', () => { + expect(calculateExpectedScore(1200, 1200)).toBe(0.5); + expect(calculateExpectedScore(1000, 1400)).toBe(0.0909); + expect(calculateExpectedScore(1400, 1000)).toBe(0.9091); + }); + + it('stays bounded for extreme rating gaps', () => { + expect(calculateExpectedScore(100, 1000000)).toBe(0); + expect(calculateExpectedScore(1000000, 100)).toBe(1); + }); +}); + +describe('dynamic K-factors', () => { + it('uses placement, regular, and elite factors at their boundaries', () => { + expect(determineKFactor(member(1200, 9))).toBe(48); + expect(determineKFactor(member(1999, 10))).toBe(32); + expect(determineKFactor(member(2000, 10))).toBe(16); + expect(determineKFactor(member(2200, 9))).toBe(48); + }); + + it('honors custom factors and placement counts, including zero', () => { + expect(determineKFactor(member(1200, 4), 20, 5)).toBe(30); + expect(determineKFactor(member(1200, 5), 20, 5)).toBe(20); + expect(determineKFactor(member(2000, 5), 20, 5)).toBe(10); + expect(determineKFactor(member(1200, 0), 20, 0)).toBe(20); + }); +}); + +describe('rank tiers', () => { + it.each([ + [100, 'novice'], [999, 'novice'], [1000, 'apprentice'], + [1399, 'apprentice'], [1400, 'adept'], [1799, 'adept'], + [1800, 'master'], [2199, 'master'], [2200, 'grandmaster'], + ] as const)('classifies %i as %s', (rating, tier) => { + expect(getMemberTier(rating)).toBe(tier); + }); +}); + +describe('Elo match results', () => { + it('awards 16 points for a win between established equals', () => { + expect(calculateEloMatch(member(), member(), 1)).toEqual({ + playerNewRating: 1216, opponentNewRating: 1184, + playerRatingDelta: 16, opponentRatingDelta: -16, + playerExpectedScore: 0.5, opponentExpectedScore: 0.5, + playerNewTier: 'apprentice', + }); + }); + + it('awards more for an upset than an expected win', () => { + expect(calculateEloMatch(member(1000), member(1400), 1).playerRatingDelta).toBe(29); + expect(calculateEloMatch(member(1400), member(1000), 1).playerRatingDelta).toBe(3); + }); + + it('leaves equal draws unchanged and moves unequal draws toward each other', () => { + expect(calculateEloMatch(member(), member(), 0.5).playerRatingDelta).toBe(0); + const draw = calculateEloMatch(member(1000), member(1400), 0.5); + expect(draw.playerRatingDelta).toBe(13); + expect(draw.opponentRatingDelta).toBe(-13); + }); + + it('uses independent placement and elite K-factors', () => { + const result = calculateEloMatch(member(2000, 0), member(2000), 1); + expect(result.playerRatingDelta).toBe(24); + expect(result.opponentRatingDelta).toBe(-8); + }); + + it('passes custom options to both members', () => { + const result = calculateEloMatch(member(1200, 2), member(1200, 3), 1, { + baseKFactor: 20, placementMatchesCount: 3, + }); + expect(result.playerRatingDelta).toBe(15); + expect(result.opponentRatingDelta).toBe(-10); + expect(calculateEloMatch(member(), member(), 1, { baseKFactor: 0 }).playerRatingDelta).toBe(0); + expect(calculateEloMatch(member(1200, 0), member(1200, 0), 1, { + placementMatchesCount: 0, + }).playerRatingDelta).toBe(16); + }); + + it('gives the streak bonus only to a qualifying winner on either side', () => { + expect(calculateEloMatch(member(1200, 10, 2), member(), 1).playerRatingDelta).toBe(16); + const win = calculateEloMatch(member(1200, 10, 3), member(1200, 10, 4), 1); + expect(win.playerRatingDelta).toBe(18); + expect(win.opponentRatingDelta).toBe(-16); + const loss = calculateEloMatch(member(1200, 10, 4), member(1200, 10, 3), 0); + expect(loss.playerRatingDelta).toBe(-16); + expect(loss.opponentRatingDelta).toBe(18); + expect(calculateEloMatch(member(), member(1200, 10, 2), 0).opponentRatingDelta).toBe(16); + }); + + it('does not award bonuses for draws or when disabled', () => { + const streak = member(1200, 10, 3); + const draw = calculateEloMatch(streak, streak, 0.5); + expect(draw.playerRatingDelta).toBe(0); + expect(draw.opponentRatingDelta).toBe(0); + expect(calculateEloMatch(streak, streak, 1, { enableStreakBonus: false }).playerRatingDelta).toBe(16); + expect(calculateEloMatch(streak, streak, 0, { enableStreakBonus: false }).opponentRatingDelta).toBe(16); + }); + + it('clamps both sides to 100 and reports only the actual rating change', () => { + const loss = calculateEloMatch(member(105), member(105), 0); + expect(loss.playerNewRating).toBe(100); + expect(loss.playerRatingDelta).toBe(-5); + const win = calculateEloMatch(member(105), member(105), 1); + expect(win.opponentNewRating).toBe(100); + expect(win.opponentRatingDelta).toBe(-5); + expect(calculateEloMatch(member(100), member(100), 0).playerRatingDelta).toBe(0); + }); + + it.each([ + [999, 'apprentice'], [1399, 'adept'], [1799, 'master'], [2199, 'grandmaster'], + ] as const)('promotes a winner across the boundary above %i', (rating, tier) => { + expect(calculateEloMatch(member(rating), member(rating), 1).playerNewTier).toBe(tier); + }); + + it('updates the tier after a loss and does not mutate input profiles', () => { + const player = Object.freeze(member(1400)); + const opponent = Object.freeze(member(1400)); + expect(calculateEloMatch(player, opponent, 0).playerNewTier).toBe('apprentice'); + expect(player).toEqual(member(1400)); + expect(opponent).toEqual(member(1400)); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 26a5452..08089fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,6 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./tests/setup.ts'], - include: ['tests/**/*.test.tsx'], + include: ['tests/**/*.test.tsx', 'tests/elo-rating.test.ts'], }, -}); \ No newline at end of file +});