From f52fd1cb30f670d99f7eafa8e53c0446bce11012 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Thu, 30 Oct 2025 21:41:21 +0100 Subject: [PATCH 01/22] Add the ratings lib --- libs/core-rating/README.md | 11 +++++++ libs/core-rating/eslint.config.cjs | 19 +++++++++++++ libs/core-rating/jest.config.ts | 10 +++++++ libs/core-rating/package.json | 11 +++++++ libs/core-rating/project.json | 30 ++++++++++++++++++++ libs/core-rating/src/index.ts | 1 + libs/core-rating/src/lib/core-rating.spec.ts | 7 +++++ libs/core-rating/src/lib/core-rating.ts | 3 ++ libs/core-rating/tsconfig.json | 16 +++++++++++ libs/core-rating/tsconfig.lib.json | 10 +++++++ libs/core-rating/tsconfig.spec.json | 15 ++++++++++ package.json | 3 ++ pnpm-lock.yaml | 17 ++++++----- tsconfig.base.json | 3 +- 14 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 libs/core-rating/README.md create mode 100644 libs/core-rating/eslint.config.cjs create mode 100644 libs/core-rating/jest.config.ts create mode 100644 libs/core-rating/package.json create mode 100644 libs/core-rating/project.json create mode 100644 libs/core-rating/src/index.ts create mode 100644 libs/core-rating/src/lib/core-rating.spec.ts create mode 100644 libs/core-rating/src/lib/core-rating.ts create mode 100644 libs/core-rating/tsconfig.json create mode 100644 libs/core-rating/tsconfig.lib.json create mode 100644 libs/core-rating/tsconfig.spec.json diff --git a/libs/core-rating/README.md b/libs/core-rating/README.md new file mode 100644 index 00000000..f21263b1 --- /dev/null +++ b/libs/core-rating/README.md @@ -0,0 +1,11 @@ +# core-rating + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build core-rating` to build the library. + +## Running unit tests + +Run `nx test core-rating` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/core-rating/eslint.config.cjs b/libs/core-rating/eslint.config.cjs new file mode 100644 index 00000000..1940c2b1 --- /dev/null +++ b/libs/core-rating/eslint.config.cjs @@ -0,0 +1,19 @@ +const baseConfig = require('../../.eslintrc.json'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: require('jsonc-eslint-parser'), + }, + }, +]; diff --git a/libs/core-rating/jest.config.ts b/libs/core-rating/jest.config.ts new file mode 100644 index 00000000..fa812653 --- /dev/null +++ b/libs/core-rating/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'core-rating', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/core-rating', +}; diff --git a/libs/core-rating/package.json b/libs/core-rating/package.json new file mode 100644 index 00000000..21cc0db0 --- /dev/null +++ b/libs/core-rating/package.json @@ -0,0 +1,11 @@ +{ + "name": "core-rating", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/libs/core-rating/project.json b/libs/core-rating/project.json new file mode 100644 index 00000000..0ea7cf10 --- /dev/null +++ b/libs/core-rating/project.json @@ -0,0 +1,30 @@ +{ + "name": "core-rating", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/core-rating/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/core-rating", + "tsConfig": "libs/core-rating/tsconfig.lib.json", + "packageJson": "libs/core-rating/package.json", + "main": "libs/core-rating/src/index.ts", + "assets": ["libs/core-rating/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/core-rating/jest.config.ts" + } + } + } +} diff --git a/libs/core-rating/src/index.ts b/libs/core-rating/src/index.ts new file mode 100644 index 00000000..c3552ed7 --- /dev/null +++ b/libs/core-rating/src/index.ts @@ -0,0 +1 @@ +export * from './lib/core-rating'; diff --git a/libs/core-rating/src/lib/core-rating.spec.ts b/libs/core-rating/src/lib/core-rating.spec.ts new file mode 100644 index 00000000..24d3c311 --- /dev/null +++ b/libs/core-rating/src/lib/core-rating.spec.ts @@ -0,0 +1,7 @@ +import { coreRating } from './core-rating'; + +describe('coreRating', () => { + it('should work', () => { + expect(coreRating()).toEqual('core-rating'); + }); +}); diff --git a/libs/core-rating/src/lib/core-rating.ts b/libs/core-rating/src/lib/core-rating.ts new file mode 100644 index 00000000..04685a64 --- /dev/null +++ b/libs/core-rating/src/lib/core-rating.ts @@ -0,0 +1,3 @@ +export function coreRating(): string { + return 'core-rating'; +} diff --git a/libs/core-rating/tsconfig.json b/libs/core-rating/tsconfig.json new file mode 100644 index 00000000..19b9eece --- /dev/null +++ b/libs/core-rating/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/core-rating/tsconfig.lib.json b/libs/core-rating/tsconfig.lib.json new file mode 100644 index 00000000..33eca2c2 --- /dev/null +++ b/libs/core-rating/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/core-rating/tsconfig.spec.json b/libs/core-rating/tsconfig.spec.json new file mode 100644 index 00000000..0d3c604e --- /dev/null +++ b/libs/core-rating/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package.json b/package.json index 2cdd89f0..778e44b7 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,9 @@ "@nx/vite": "21.6.4", "@nx/web": "21.6.4", "@nx/workspace": "21.6.4", + "@swc-node/register": "~1.9.1", + "@swc/core": "~1.5.7", + "@swc/helpers": "~0.5.11", "@tanstack/react-query-devtools": "^5.87.4", "@tanstack/react-router-devtools": "^1.131.42", "@testing-library/dom": "^10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1efb5c7a..f0f84b03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,15 @@ importers: '@nx/workspace': specifier: 21.6.4 version: 21.6.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)) + '@swc-node/register': + specifier: ~1.9.1 + version: 1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.9.3) + '@swc/core': + specifier: ~1.5.7 + version: 1.5.29(@swc/helpers@0.5.17) + '@swc/helpers': + specifier: ~0.5.11 + version: 0.5.17 '@tanstack/react-query-devtools': specifier: ^5.87.4 version: 5.87.4(@tanstack/react-query@5.90.2(react@19.1.0))(react@19.1.0) @@ -14659,7 +14668,6 @@ snapshots: dependencies: '@swc/core': 1.5.29(@swc/helpers@0.5.17) '@swc/types': 0.1.24 - optional: true '@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.24)(typescript@5.9.3)': dependencies: @@ -14674,13 +14682,11 @@ snapshots: transitivePeerDependencies: - '@swc/types' - supports-color - optional: true '@swc-node/sourcemap-support@0.5.1': dependencies: source-map-support: 0.5.21 tslib: 2.8.1 - optional: true '@swc/core-darwin-arm64@1.5.29': optional: true @@ -14728,10 +14734,8 @@ snapshots: '@swc/core-win32-ia32-msvc': 1.5.29 '@swc/core-win32-x64-msvc': 1.5.29 '@swc/helpers': 0.5.17 - optional: true - '@swc/counter@0.1.3': - optional: true + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.17': dependencies: @@ -14740,7 +14744,6 @@ snapshots: '@swc/types@0.1.24': dependencies: '@swc/counter': 0.1.3 - optional: true '@tanstack/history@1.132.31': {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 32bf8ea1..c6d19fc5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,7 +28,8 @@ "@michess/infra-email": ["libs/infra-email/src/index.ts"], "@michess/react-chessboard": ["libs/react-chessboard/src/index.ts"], "@michess/react-dnd": ["libs/react-dnd/src/index.ts"], - "@michess/react-emails": ["libs/react-emails/src/index.ts"] + "@michess/react-emails": ["libs/react-emails/src/index.ts"], + "@michess/core-rating": ["libs/core-rating/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] From 22d7134f578327720602654523588804c474ea08 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sat, 1 Nov 2025 21:53:53 +0100 Subject: [PATCH 02/22] Add GlickoTwo algorithm --- libs/core-rating/src/lib/GlickoTwo.ts | 171 +++++++++++++++++++ libs/core-rating/src/lib/core-rating.spec.ts | 7 - libs/core-rating/src/lib/core-rating.ts | 3 - libs/core-rating/src/lib/model/GameResult.ts | 6 + libs/core-rating/src/lib/model/Player.ts | 5 + 5 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 libs/core-rating/src/lib/GlickoTwo.ts delete mode 100644 libs/core-rating/src/lib/core-rating.spec.ts delete mode 100644 libs/core-rating/src/lib/core-rating.ts create mode 100644 libs/core-rating/src/lib/model/GameResult.ts create mode 100644 libs/core-rating/src/lib/model/Player.ts diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts new file mode 100644 index 00000000..24736c17 --- /dev/null +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -0,0 +1,171 @@ +import { Maybe } from '@michess/common-utils'; +import { GameResult } from './model/GameResult'; +import { Player } from './model/Player'; + +const TAO = 0.5; +const GLICKO_SCALE_DENOMINATOR = 173.7178; +const DEFAULT_PLAYER: Player = { + rating: 1500, + deviation: 350, + volatility: 0.06, +}; + +const convertRatingToGlickoScale = (rating: number) => { + return (rating - 1500) / GLICKO_SCALE_DENOMINATOR; +}; +const convertDeviationToGlickoScale = (deviation: number) => { + return deviation / GLICKO_SCALE_DENOMINATOR; +}; +const convertToGlickoScale = (player: Player): { phi: number; mu: number } => { + return { + mu: convertRatingToGlickoScale(player.rating), + phi: convertDeviationToGlickoScale(player.deviation), + }; +}; + +const convertRatingFromGlickoScale = (glickoRating: number) => { + return glickoRating * GLICKO_SCALE_DENOMINATOR + 1500; +}; +const convertDeviationFromGlickoScale = (glickoDeviation: number) => { + return glickoDeviation * GLICKO_SCALE_DENOMINATOR; +}; +const convertFromGlickoScale = ( + glickoRating: number, + glickoDeviation: number, +): { rating: number; deviation: number } => { + return { + rating: convertRatingFromGlickoScale(glickoRating), + deviation: convertDeviationFromGlickoScale(glickoDeviation), + }; +}; + +const g = (glickoDeviation: number) => { + return ( + 1 / Math.sqrt(1 + (3 * Math.pow(glickoDeviation, 2)) / Math.pow(Math.PI, 2)) + ); +}; + +const E = ( + playerGlickoRating: number, + opponentGlickoRating: number, + opponentGlickoDeviation: number, +) => { + return ( + 1 / + (1 + + Math.exp( + -g(opponentGlickoDeviation) * + (playerGlickoRating - opponentGlickoRating), + )) + ); +}; + +const f = ( + delta: number, + phi: number, + upsilon: number, + sigma: number, +): ((x: number) => number) => { + const a = Math.log(Math.pow(sigma, 2)); + return (x: number) => { + const ex = Math.exp(x); + const numerator = + ex * (Math.pow(delta, 2) - Math.pow(phi, 2) - upsilon - ex); + const denominator = 2 * Math.pow(Math.pow(phi, 2) + upsilon + ex, 2); + return numerator / denominator - (x - a) / Math.pow(TAO, 2); + }; +}; + +const algorithm = (player: Maybe, gameResults: GameResult[]) => { + const actualPlayer = player || DEFAULT_PLAYER; + + const { rating, deviation, volatility } = actualPlayer; + + // Step 2: Convert to Glicko-2 scale + const { phi, mu } = convertToGlickoScale(actualPlayer); + const siigma = volatility; + + // Step 3: Compute the estimated variance + const upsilon = + 1 / + gameResults.reduce((sum, gameResult) => { + const opponentPlayer = convertToGlickoScale(gameResult.opponent); + const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); + return sum + Math.pow(g(opponentPlayer.phi), 2) * EValue * (1 - EValue); + }, 0); + + // Step 4: Compute the estimated rating improvement + const delta = + upsilon * + gameResults.reduce((sum, gameResult) => { + const opponentPlayer = convertToGlickoScale(gameResult.opponent); + const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); + return sum + g(opponentPlayer.phi) * (gameResult.score - EValue); + }, 0); + + // Step 5: Determine new volatility + const fFunction = f(delta, phi, upsilon, siigma); + const deltaSquared = Math.pow(delta, 2); + const phiSquared = Math.pow(phi, 2); + let alpha = Math.log(Math.pow(siigma, 2)); + + const iterateKappa = (kappa: number): number => { + if (fFunction(alpha - kappa * TAO) < 0) { + return iterateKappa(kappa + 1); + } else { + return kappa; + } + }; + + let beta = + deltaSquared > phiSquared + upsilon + ? Math.log(deltaSquared - phiSquared - upsilon) + : alpha - iterateKappa(1) * TAO; + let fAlpha = fFunction(alpha); + let fBeta = fFunction(beta); + + const EPSILON = 0.000001; + + while (Math.abs(beta - alpha) > EPSILON) { + const c = alpha + ((alpha - beta) * fAlpha) / (fBeta - fAlpha); + const fC = fFunction(c); + if (fC * fBeta < 0) { + alpha = beta; + fAlpha = fBeta; + } else { + fAlpha = fAlpha / 2; + } + beta = c; + fBeta = fC; + } + const newSigma = Math.exp(alpha / 2); + + // Step 6: Update deviation to new pre-rating deviation + const phiStar = Math.sqrt(Math.pow(phi, 2) + Math.pow(newSigma, 2)); + + // Step 7: Update ratomg and deviation + const newPhi = 1 / Math.sqrt(1 / Math.pow(phiStar, 2) + 1 / upsilon); + const newMu = + mu + + Math.pow(newPhi, 2) * + gameResults.reduce((sum, gameResult) => { + const opponentPlayer = convertToGlickoScale(gameResult.opponent); + const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); + return sum + g(opponentPlayer.phi) * (gameResult.score - EValue); + }, 0); + + const { rating: newRating, deviation: newDeviation } = convertFromGlickoScale( + newMu, + newPhi, + ); + + return { + rating: newRating, + deviation: newDeviation, + volatility: newSigma, + }; +}; + +export const GlickoTwo = { + algorithm, +}; diff --git a/libs/core-rating/src/lib/core-rating.spec.ts b/libs/core-rating/src/lib/core-rating.spec.ts deleted file mode 100644 index 24d3c311..00000000 --- a/libs/core-rating/src/lib/core-rating.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { coreRating } from './core-rating'; - -describe('coreRating', () => { - it('should work', () => { - expect(coreRating()).toEqual('core-rating'); - }); -}); diff --git a/libs/core-rating/src/lib/core-rating.ts b/libs/core-rating/src/lib/core-rating.ts deleted file mode 100644 index 04685a64..00000000 --- a/libs/core-rating/src/lib/core-rating.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function coreRating(): string { - return 'core-rating'; -} diff --git a/libs/core-rating/src/lib/model/GameResult.ts b/libs/core-rating/src/lib/model/GameResult.ts new file mode 100644 index 00000000..90d92578 --- /dev/null +++ b/libs/core-rating/src/lib/model/GameResult.ts @@ -0,0 +1,6 @@ +import { Player } from './Player'; + +export type GameResult = { + opponent: Player; + score: number; // 1 = win, 0.5 = draw, 0 = loss +}; diff --git a/libs/core-rating/src/lib/model/Player.ts b/libs/core-rating/src/lib/model/Player.ts new file mode 100644 index 00000000..4ab26202 --- /dev/null +++ b/libs/core-rating/src/lib/model/Player.ts @@ -0,0 +1,5 @@ +export type Player = { + rating: number; + deviation: number; + volatility: number; +}; From 4d7a45eddafacbd8adf4548d0e8a402c0f5ddc23 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sun, 2 Nov 2025 22:24:45 +0100 Subject: [PATCH 03/22] Add test --- .../src/lib/__tests__/GlickoTwo.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts diff --git a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts new file mode 100644 index 00000000..6951d321 --- /dev/null +++ b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts @@ -0,0 +1,29 @@ +import { GlickoTwo } from '../GlickoTwo'; +import { Player } from '../model/Player'; +describe('GlickoTwo', () => { + it('should handle the example calculation from the report', () => { + const player: Player = { + rating: 1500, + deviation: 200, + volatility: 0.06, + }; + const result = GlickoTwo.algorithm(player, [ + { opponent: { rating: 1400, deviation: 30, volatility: 0.06 }, score: 1 }, + { + opponent: { rating: 1550, deviation: 100, volatility: 0.06 }, + score: 0, + }, + { + opponent: { rating: 1700, deviation: 300, volatility: 0.06 }, + score: 0, + }, + ]); + + // Paper results are 1464.06, but it is not correct due to + // rounding in the intermediate steps: + // https://github.com/andriykuba/scala-glicko2?tab=readme-ov-file#precision + expect(result.rating.toFixed(2)).toBe('1464.05'); + expect(result.deviation.toFixed(2)).toBe('151.52'); + expect(result.volatility).toBeCloseTo(0.05999, 4); + }); +}); From 59ce873e5fd9065d967f6cbcefcaa4e1b0732686 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 3 Nov 2025 12:53:27 +0100 Subject: [PATCH 04/22] Add support for fractional rating periods --- libs/core-rating/src/lib/GlickoTwo.ts | 70 ++++++++++++++++++--------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts index 24736c17..90d47c9e 100644 --- a/libs/core-rating/src/lib/GlickoTwo.ts +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -9,6 +9,7 @@ const DEFAULT_PLAYER: Player = { deviation: 350, volatility: 0.06, }; +const CONVERGENCE_EPSILON = 0.000001; const convertRatingToGlickoScale = (rating: number) => { return (rating - 1500) / GLICKO_SCALE_DENOMINATOR; @@ -76,14 +77,38 @@ const f = ( }; }; -const algorithm = (player: Maybe, gameResults: GameResult[]) => { - const actualPlayer = player || DEFAULT_PLAYER; - - const { rating, deviation, volatility } = actualPlayer; +const updateRatingDeviation = ( + oldDeviation: number, + volatility: number, + elapsedPeriodsSinceLastUpdate: number, +): number => { + return Math.sqrt( + Math.pow(oldDeviation, 2) + + elapsedPeriodsSinceLastUpdate * Math.pow(volatility, 2), + ); +}; +const algorithm = ( + player: Maybe, + gameResults: GameResult[], + elapsedPeriodsSinceLastUpdate = 1, +): Player => { + const actualPlayer = player || DEFAULT_PLAYER; // Step 2: Convert to Glicko-2 scale const { phi, mu } = convertToGlickoScale(actualPlayer); - const siigma = volatility; + const sigma = actualPlayer.volatility; + + if (gameResults.length === 0) { + return { + rating: actualPlayer.rating, + deviation: updateRatingDeviation( + phi, + sigma, + elapsedPeriodsSinceLastUpdate, + ), + volatility: actualPlayer.volatility, + }; + } // Step 3: Compute the estimated variance const upsilon = @@ -95,19 +120,21 @@ const algorithm = (player: Maybe, gameResults: GameResult[]) => { }, 0); // Step 4: Compute the estimated rating improvement - const delta = - upsilon * - gameResults.reduce((sum, gameResult) => { + const gameOutcomeRatingImprovementFactor = gameResults.reduce( + (sum, gameResult) => { const opponentPlayer = convertToGlickoScale(gameResult.opponent); const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); return sum + g(opponentPlayer.phi) * (gameResult.score - EValue); - }, 0); + }, + 0, + ); + const delta = upsilon * gameOutcomeRatingImprovementFactor; // Step 5: Determine new volatility - const fFunction = f(delta, phi, upsilon, siigma); + const fFunction = f(delta, phi, upsilon, sigma); const deltaSquared = Math.pow(delta, 2); const phiSquared = Math.pow(phi, 2); - let alpha = Math.log(Math.pow(siigma, 2)); + let alpha = Math.log(Math.pow(sigma, 2)); const iterateKappa = (kappa: number): number => { if (fFunction(alpha - kappa * TAO) < 0) { @@ -124,9 +151,7 @@ const algorithm = (player: Maybe, gameResults: GameResult[]) => { let fAlpha = fFunction(alpha); let fBeta = fFunction(beta); - const EPSILON = 0.000001; - - while (Math.abs(beta - alpha) > EPSILON) { + while (Math.abs(beta - alpha) > CONVERGENCE_EPSILON) { const c = alpha + ((alpha - beta) * fAlpha) / (fBeta - fAlpha); const fC = fFunction(c); if (fC * fBeta < 0) { @@ -141,18 +166,15 @@ const algorithm = (player: Maybe, gameResults: GameResult[]) => { const newSigma = Math.exp(alpha / 2); // Step 6: Update deviation to new pre-rating deviation - const phiStar = Math.sqrt(Math.pow(phi, 2) + Math.pow(newSigma, 2)); + const phiStar = updateRatingDeviation( + phi, + newSigma, + elapsedPeriodsSinceLastUpdate, + ); - // Step 7: Update ratomg and deviation + // Step 7: Update rating and deviation const newPhi = 1 / Math.sqrt(1 / Math.pow(phiStar, 2) + 1 / upsilon); - const newMu = - mu + - Math.pow(newPhi, 2) * - gameResults.reduce((sum, gameResult) => { - const opponentPlayer = convertToGlickoScale(gameResult.opponent); - const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); - return sum + g(opponentPlayer.phi) * (gameResult.score - EValue); - }, 0); + const newMu = mu + Math.pow(newPhi, 2) * gameOutcomeRatingImprovementFactor; const { rating: newRating, deviation: newDeviation } = convertFromGlickoScale( newMu, From 5565e245510590da30bcac93a859b9607640eda9 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 3 Nov 2025 19:40:16 +0100 Subject: [PATCH 05/22] Add test for when no games has been played --- libs/core-rating/src/lib/GlickoTwo.ts | 13 +++++-------- .../core-rating/src/lib/__tests__/GlickoTwo.spec.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts index 90d47c9e..f84599c2 100644 --- a/libs/core-rating/src/lib/GlickoTwo.ts +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -78,13 +78,12 @@ const f = ( }; const updateRatingDeviation = ( - oldDeviation: number, - volatility: number, + phi: number, + sigma: number, elapsedPeriodsSinceLastUpdate: number, ): number => { return Math.sqrt( - Math.pow(oldDeviation, 2) + - elapsedPeriodsSinceLastUpdate * Math.pow(volatility, 2), + Math.pow(phi, 2) + elapsedPeriodsSinceLastUpdate * Math.pow(sigma, 2), ); }; @@ -101,10 +100,8 @@ const algorithm = ( if (gameResults.length === 0) { return { rating: actualPlayer.rating, - deviation: updateRatingDeviation( - phi, - sigma, - elapsedPeriodsSinceLastUpdate, + deviation: convertDeviationFromGlickoScale( + updateRatingDeviation(phi, sigma, elapsedPeriodsSinceLastUpdate), ), volatility: actualPlayer.volatility, }; diff --git a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts index 6951d321..baabd8e6 100644 --- a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts +++ b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts @@ -26,4 +26,17 @@ describe('GlickoTwo', () => { expect(result.deviation.toFixed(2)).toBe('151.52'); expect(result.volatility).toBeCloseTo(0.05999, 4); }); + + it('should handle a player with no games played', () => { + const player: Player = { + rating: 1500, + deviation: 60, + volatility: 0.06, + }; + const result = GlickoTwo.algorithm(player, [], 50); + + expect(result.rating).toBe(1500); + expect(result.deviation).toBeCloseTo(95, 1); + expect(result.volatility).toBe(0.06); + }); }); From 2498b4b02a2b155ecb2d6c7f8af9fc54df812f5b Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Thu, 6 Nov 2025 11:45:58 +0100 Subject: [PATCH 06/22] Rename Player to RatingProfile --- libs/core-rating/src/lib/GlickoTwo.ts | 12 +++++++----- libs/core-rating/src/lib/model/GameResult.ts | 4 ++-- libs/core-rating/src/lib/model/Player.ts | 8 ++++---- libs/core-rating/src/lib/model/RatingProfile.ts | 5 +++++ 4 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 libs/core-rating/src/lib/model/RatingProfile.ts diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts index f84599c2..1960b6a8 100644 --- a/libs/core-rating/src/lib/GlickoTwo.ts +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -1,10 +1,10 @@ import { Maybe } from '@michess/common-utils'; import { GameResult } from './model/GameResult'; -import { Player } from './model/Player'; +import { RatingProfile } from './model/RatingProfile'; const TAO = 0.5; const GLICKO_SCALE_DENOMINATOR = 173.7178; -const DEFAULT_PLAYER: Player = { +const DEFAULT_PLAYER: RatingProfile = { rating: 1500, deviation: 350, volatility: 0.06, @@ -17,7 +17,9 @@ const convertRatingToGlickoScale = (rating: number) => { const convertDeviationToGlickoScale = (deviation: number) => { return deviation / GLICKO_SCALE_DENOMINATOR; }; -const convertToGlickoScale = (player: Player): { phi: number; mu: number } => { +const convertToGlickoScale = ( + player: RatingProfile, +): { phi: number; mu: number } => { return { mu: convertRatingToGlickoScale(player.rating), phi: convertDeviationToGlickoScale(player.deviation), @@ -88,10 +90,10 @@ const updateRatingDeviation = ( }; const algorithm = ( - player: Maybe, + player: Maybe, gameResults: GameResult[], elapsedPeriodsSinceLastUpdate = 1, -): Player => { +): RatingProfile => { const actualPlayer = player || DEFAULT_PLAYER; // Step 2: Convert to Glicko-2 scale const { phi, mu } = convertToGlickoScale(actualPlayer); diff --git a/libs/core-rating/src/lib/model/GameResult.ts b/libs/core-rating/src/lib/model/GameResult.ts index 90d92578..b13c27e3 100644 --- a/libs/core-rating/src/lib/model/GameResult.ts +++ b/libs/core-rating/src/lib/model/GameResult.ts @@ -1,6 +1,6 @@ -import { Player } from './Player'; +import { RatingProfile } from './RatingProfile'; export type GameResult = { - opponent: Player; + opponent: RatingProfile; score: number; // 1 = win, 0.5 = draw, 0 = loss }; diff --git a/libs/core-rating/src/lib/model/Player.ts b/libs/core-rating/src/lib/model/Player.ts index 4ab26202..0fa02c8c 100644 --- a/libs/core-rating/src/lib/model/Player.ts +++ b/libs/core-rating/src/lib/model/Player.ts @@ -1,5 +1,5 @@ -export type Player = { - rating: number; - deviation: number; - volatility: number; +import { RatingProfile } from './RatingProfile'; + +export type Player = RatingProfile & { + timestamp: number; }; diff --git a/libs/core-rating/src/lib/model/RatingProfile.ts b/libs/core-rating/src/lib/model/RatingProfile.ts new file mode 100644 index 00000000..fab410d9 --- /dev/null +++ b/libs/core-rating/src/lib/model/RatingProfile.ts @@ -0,0 +1,5 @@ +export type RatingProfile = { + rating: number; + deviation: number; + volatility: number; +}; From e9b08e34d8886206bd9db14500d0d81b4a5bb166 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Thu, 6 Nov 2025 12:34:42 +0100 Subject: [PATCH 07/22] Add ratings table and migration files --- .../migrations/0002_tired_juggernaut.sql | 18 + .../migrations/meta/0002_snapshot.json | 781 ++++++++++++++++++ .../generated/migrations/meta/_journal.json | 7 + libs/infra-db/src/lib/model/InsertRating.ts | 3 + libs/infra-db/src/lib/model/SelectRating.ts | 3 + libs/infra-db/src/lib/schema/games.ts | 17 +- libs/infra-db/src/lib/schema/index.ts | 1 + libs/infra-db/src/lib/schema/ratings.ts | 71 ++ 8 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql create mode 100644 libs/infra-db/src/generated/migrations/meta/0002_snapshot.json create mode 100644 libs/infra-db/src/lib/model/InsertRating.ts create mode 100644 libs/infra-db/src/lib/model/SelectRating.ts create mode 100644 libs/infra-db/src/lib/schema/ratings.ts diff --git a/libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql b/libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql new file mode 100644 index 00000000..d6087117 --- /dev/null +++ b/libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql @@ -0,0 +1,18 @@ +CREATE TABLE "ratings" ( + "id" serial PRIMARY KEY NOT NULL, + "player_id" text NOT NULL, + "game_id" uuid, + "variant" "variant" NOT NULL, + "time_control_classification" time_control_classification NOT NULL, + "rating" real NOT NULL, + "deviation" real NOT NULL, + "volatility" real NOT NULL, + "timestamp" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_player_id_users_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_id_games_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("game_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_variant_tc_fk" FOREIGN KEY ("game_id","variant","time_control_classification") REFERENCES "public"."games"("game_id","variant","time_control_classification") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_ratings_player_variant_tc_created" ON "ratings" USING btree ("player_id","variant","time_control_classification","timestamp" DESC NULLS LAST);--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "game_variant_tc_unique" UNIQUE("game_id","variant","time_control_classification"); \ No newline at end of file diff --git a/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json b/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..3022ff4c --- /dev/null +++ b/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json @@ -0,0 +1,781 @@ +{ + "id": "4316715b-5adb-46c2-8271-d9f5974593ab", + "prevId": "ad053b9e-f9bd-47f1-95d6-cbe91a4254c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "action_id": { + "name": "action_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "color", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "move_number": { + "name": "move_number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "action_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "actions_game_id_games_game_id_fk": { + "name": "actions_game_id_games_game_id_fk", + "tableFrom": "actions", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "game_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.games": { + "name": "games", + "schema": "", + "columns": { + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "variant": { + "name": "variant", + "type": "variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "white_player_id": { + "name": "white_player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "black_player_id": { + "name": "black_player_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_control_classification": { + "name": "time_control_classification", + "type": "time_control_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'no_clock'" + }, + "time_control": { + "name": "time_control", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "game_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'empty'" + }, + "result": { + "name": "result", + "type": "result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'0-0'" + }, + "result_reason": { + "name": "result_reason", + "type": "result_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "games_white_player_id_users_id_fk": { + "name": "games_white_player_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": [ + "white_player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "games_black_player_id_users_id_fk": { + "name": "games_black_player_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": [ + "black_player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_variant_tc_unique": { + "name": "game_variant_tc_unique", + "nullsNotDistinct": false, + "columns": [ + "game_id", + "variant", + "time_control_classification" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_control_standard_check": { + "name": "time_control_standard_check", + "value": "(\n \"games\".\"time_control_classification\" NOT IN ('bullet', 'blitz', 'rapid')\n OR (\n \"games\".\"time_control\" IS NOT NULL\n AND \"games\".\"time_control\"->>'initial' IS NOT NULL\n AND \"games\".\"time_control\"->>'increment' IS NOT NULL\n )\n )" + }, + "time_control_correspondence_check": { + "name": "time_control_correspondence_check", + "value": "(\n \"games\".\"time_control_classification\" != 'correspondence'\n OR (\n \"games\".\"time_control\" IS NOT NULL\n AND \"games\".\"time_control\"->>'daysPerMove' IS NOT NULL\n )\n )" + }, + "time_control_no_clock_check": { + "name": "time_control_no_clock_check", + "value": "(\n \"games\".\"time_control_classification\" != 'no_clock'\n OR \"games\".\"time_control\" IS NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.moves": { + "name": "moves", + "schema": "", + "columns": { + "move_id": { + "name": "move_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uci": { + "name": "uci", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "moved_at": { + "name": "moved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "moves_game_id_games_game_id_fk": { + "name": "moves_game_id_games_game_id_fk", + "tableFrom": "moves", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "game_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "time_control_classification": { + "name": "time_control_classification", + "type": "time_control_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "deviation": { + "name": "deviation", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "volatility": { + "name": "volatility", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ratings_player_variant_tc_created": { + "name": "idx_ratings_player_variant_tc_created", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "time_control_classification", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ratings_player_id_users_id_fk": { + "name": "ratings_player_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_game_id_games_game_id_fk": { + "name": "ratings_game_id_games_game_id_fk", + "tableFrom": "ratings", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "game_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ratings_game_variant_tc_fk": { + "name": "ratings_game_variant_tc_fk", + "tableFrom": "ratings", + "tableTo": "games", + "columnsFrom": [ + "game_id", + "variant", + "time_control_classification" + ], + "columnsTo": [ + "game_id", + "variant", + "time_control_classification" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action_type": { + "name": "action_type", + "schema": "public", + "values": [ + "accept_draw", + "offer_draw", + "resign" + ] + }, + "public.color": { + "name": "color", + "schema": "public", + "values": [ + "white", + "black" + ] + }, + "public.game_status": { + "name": "game_status", + "schema": "public", + "values": [ + "empty", + "waiting", + "ready", + "in-progress", + "end" + ] + }, + "public.result": { + "name": "result", + "schema": "public", + "values": [ + "1-0", + "0-1", + "1/2-1/2", + "0-0" + ] + }, + "public.result_reason": { + "name": "result_reason", + "schema": "public", + "values": [ + "checkmate", + "stalemate", + "timeout", + "resignation", + "abandoned" + ] + }, + "public.time_control_classification": { + "name": "time_control_classification", + "schema": "public", + "values": [ + "correspondence", + "blitz", + "bullet", + "rapid", + "no_clock" + ] + }, + "public.variant": { + "name": "variant", + "schema": "public", + "values": [ + "standard" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/libs/infra-db/src/generated/migrations/meta/_journal.json b/libs/infra-db/src/generated/migrations/meta/_journal.json index f436280e..14bca8c1 100644 --- a/libs/infra-db/src/generated/migrations/meta/_journal.json +++ b/libs/infra-db/src/generated/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1761126887772, "tag": "0001_default_username_trigger", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1762428763396, + "tag": "0002_tired_juggernaut", + "breakpoints": true } ] } \ No newline at end of file diff --git a/libs/infra-db/src/lib/model/InsertRating.ts b/libs/infra-db/src/lib/model/InsertRating.ts new file mode 100644 index 00000000..1303f1a6 --- /dev/null +++ b/libs/infra-db/src/lib/model/InsertRating.ts @@ -0,0 +1,3 @@ +import { ratings } from '../schema/ratings'; + +export type InsertRating = typeof ratings.$inferInsert; diff --git a/libs/infra-db/src/lib/model/SelectRating.ts b/libs/infra-db/src/lib/model/SelectRating.ts new file mode 100644 index 00000000..056bacfe --- /dev/null +++ b/libs/infra-db/src/lib/model/SelectRating.ts @@ -0,0 +1,3 @@ +import { ratings } from '../schema/ratings'; + +export type SelectRating = typeof ratings.$inferSelect; diff --git a/libs/infra-db/src/lib/schema/games.ts b/libs/infra-db/src/lib/schema/games.ts index 93c2cfb9..2c7bee1e 100644 --- a/libs/infra-db/src/lib/schema/games.ts +++ b/libs/infra-db/src/lib/schema/games.ts @@ -6,6 +6,7 @@ import { pgTable, text, timestamp, + unique, uuid, } from 'drizzle-orm/pg-core'; import { TimeControlJsonB } from '../model/TimeControlJsonB'; @@ -47,8 +48,8 @@ export const games = pgTable( startedAt: timestamp('started_at'), endedAt: timestamp('ended_at'), }, - (table) => ({ - timeControlStandardCheck: check( + (table) => [ + check( 'time_control_standard_check', sql`( ${table.timeControlClassification} NOT IN ('bullet', 'blitz', 'rapid') @@ -59,7 +60,7 @@ export const games = pgTable( ) )`, ), - timeControlCorrespondenceCheck: check( + check( 'time_control_correspondence_check', sql`( ${table.timeControlClassification} != 'correspondence' @@ -69,14 +70,20 @@ export const games = pgTable( ) )`, ), - timeControlNoClockCheck: check( + check( 'time_control_no_clock_check', sql`( ${table.timeControlClassification} != 'no_clock' OR ${table.timeControl} IS NULL )`, ), - }), + // Composite unique constraint for foreign key reference + unique('game_variant_tc_unique').on( + table.gameId, + table.variant, + table.timeControlClassification, + ), + ], ); export const gamesRelations = relations(games, ({ many, one }) => ({ diff --git a/libs/infra-db/src/lib/schema/index.ts b/libs/infra-db/src/lib/schema/index.ts index 0e8beae2..4f2e522d 100644 --- a/libs/infra-db/src/lib/schema/index.ts +++ b/libs/infra-db/src/lib/schema/index.ts @@ -2,6 +2,7 @@ export * from './accounts'; export * from './actions'; export * from './games'; export * from './moves'; +export * from './ratings'; export * from './shared/colorEnum'; export * from './shared/gameStatusEnum'; export * from './shared/resultEnum'; diff --git a/libs/infra-db/src/lib/schema/ratings.ts b/libs/infra-db/src/lib/schema/ratings.ts new file mode 100644 index 00000000..04e98b2b --- /dev/null +++ b/libs/infra-db/src/lib/schema/ratings.ts @@ -0,0 +1,71 @@ +import { relations } from 'drizzle-orm'; +import { + foreignKey, + index, + pgTable, + real, + serial, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; +import { games } from './games'; +import { timeControlClassificationEnum } from './shared/timeControlClassificationEnum'; +import { variantEnum } from './shared/variantEnum'; +import { users } from './users'; +export const ratings = pgTable( + 'ratings', + { + id: serial('id').primaryKey().notNull(), + playerId: text('player_id') + .notNull() + .references(() => users.id, { + onDelete: 'cascade', + }), + gameId: uuid('game_id').references(() => games.gameId, { + onDelete: 'set null', + }), + variant: variantEnum().notNull(), + timeControlClassification: timeControlClassificationEnum( + 'time_control_classification', + ).notNull(), + rating: real('rating').notNull(), + deviation: real('deviation').notNull(), + volatility: real('volatility').notNull(), + timestamp: timestamp('timestamp').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('idx_ratings_player_variant_tc_created').on( + table.playerId, + table.variant, + table.timeControlClassification, + table.timestamp.desc(), + ), + // Composite foreign key constraint + foreignKey({ + columns: [table.gameId, table.variant, table.timeControlClassification], + foreignColumns: [ + games.gameId, + games.variant, + games.timeControlClassification, + ], + name: 'ratings_game_variant_tc_fk', + }), + ], +); + +export const ratingsRelations = relations(ratings, ({ one }) => ({ + player: one(users, { + fields: [ratings.playerId], + references: [users.id], + }), + game: one(games, { + fields: [ + ratings.gameId, + ratings.variant, + ratings.timeControlClassification, + ], + references: [games.gameId, games.variant, games.timeControlClassification], + }), +})); From e0a41b99c3c76a161c6c2fccc16cbb3fe108f7c6 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Thu, 6 Nov 2025 12:42:18 +0100 Subject: [PATCH 08/22] Fix tests --- libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts index baabd8e6..8866a8a3 100644 --- a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts +++ b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts @@ -1,8 +1,9 @@ import { GlickoTwo } from '../GlickoTwo'; -import { Player } from '../model/Player'; +import { RatingProfile } from '../model/RatingProfile'; + describe('GlickoTwo', () => { it('should handle the example calculation from the report', () => { - const player: Player = { + const player: RatingProfile = { rating: 1500, deviation: 200, volatility: 0.06, @@ -28,7 +29,7 @@ describe('GlickoTwo', () => { }); it('should handle a player with no games played', () => { - const player: Player = { + const player: RatingProfile = { rating: 1500, deviation: 60, volatility: 0.06, From ed44580c33ab88c0b3fbfd7fe22fe54850952664 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Thu, 6 Nov 2025 13:15:05 +0100 Subject: [PATCH 09/22] Add rating snapshots to games table --- ...juggernaut.sql => 0002_bored_gargoyle.sql} | 4 ++ .../migrations/meta/0002_snapshot.json | 40 ++++++++++++++++++- .../generated/migrations/meta/_journal.json | 4 +- .../src/lib/repository/RatingRepository.ts | 11 +++++ libs/infra-db/src/lib/schema/games.ts | 25 ++++++++++++ libs/infra-db/src/lib/schema/ratings.ts | 1 + 6 files changed, 82 insertions(+), 3 deletions(-) rename libs/infra-db/src/generated/migrations/{0002_tired_juggernaut.sql => 0002_bored_gargoyle.sql} (70%) create mode 100644 libs/infra-db/src/lib/repository/RatingRepository.ts diff --git a/libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql b/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql similarity index 70% rename from libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql rename to libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql index d6087117..a049cc27 100644 --- a/libs/infra-db/src/generated/migrations/0002_tired_juggernaut.sql +++ b/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql @@ -11,8 +11,12 @@ CREATE TABLE "ratings" ( "created_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint +ALTER TABLE "games" ADD COLUMN "white_rating_id" integer;--> statement-breakpoint +ALTER TABLE "games" ADD COLUMN "black_rating_id" integer;--> statement-breakpoint ALTER TABLE "ratings" ADD CONSTRAINT "ratings_player_id_users_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_id_games_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("game_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_variant_tc_fk" FOREIGN KEY ("game_id","variant","time_control_classification") REFERENCES "public"."games"("game_id","variant","time_control_classification") ON DELETE no action ON UPDATE no action;--> statement-breakpoint CREATE INDEX "idx_ratings_player_variant_tc_created" ON "ratings" USING btree ("player_id","variant","time_control_classification","timestamp" DESC NULLS LAST);--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "games_white_rating_id_ratings_id_fk" FOREIGN KEY ("white_rating_id") REFERENCES "public"."ratings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "games_black_rating_id_ratings_id_fk" FOREIGN KEY ("black_rating_id") REFERENCES "public"."ratings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "games" ADD CONSTRAINT "game_variant_tc_unique" UNIQUE("game_id","variant","time_control_classification"); \ No newline at end of file diff --git a/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json b/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json index 3022ff4c..b780f1f6 100644 --- a/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json +++ b/libs/infra-db/src/generated/migrations/meta/0002_snapshot.json @@ -1,5 +1,5 @@ { - "id": "4316715b-5adb-46c2-8271-d9f5974593ab", + "id": "03b319a6-4160-4703-8068-36d0cf5eb0bf", "prevId": "ad053b9e-f9bd-47f1-95d6-cbe91a4254c8", "version": "7", "dialect": "postgresql", @@ -221,6 +221,18 @@ "primaryKey": false, "notNull": false }, + "white_rating_id": { + "name": "white_rating_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "black_rating_id": { + "name": "black_rating_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, "time_control_classification": { "name": "time_control_classification", "type": "time_control_classification", @@ -312,6 +324,32 @@ ], "onDelete": "set null", "onUpdate": "no action" + }, + "games_white_rating_id_ratings_id_fk": { + "name": "games_white_rating_id_ratings_id_fk", + "tableFrom": "games", + "tableTo": "ratings", + "columnsFrom": [ + "white_rating_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "games_black_rating_id_ratings_id_fk": { + "name": "games_black_rating_id_ratings_id_fk", + "tableFrom": "games", + "tableTo": "ratings", + "columnsFrom": [ + "black_rating_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, diff --git a/libs/infra-db/src/generated/migrations/meta/_journal.json b/libs/infra-db/src/generated/migrations/meta/_journal.json index 14bca8c1..6524b060 100644 --- a/libs/infra-db/src/generated/migrations/meta/_journal.json +++ b/libs/infra-db/src/generated/migrations/meta/_journal.json @@ -19,8 +19,8 @@ { "idx": 2, "version": "7", - "when": 1762428763396, - "tag": "0002_tired_juggernaut", + "when": 1762431195086, + "tag": "0002_bored_gargoyle", "breakpoints": true } ] diff --git a/libs/infra-db/src/lib/repository/RatingRepository.ts b/libs/infra-db/src/lib/repository/RatingRepository.ts new file mode 100644 index 00000000..a4a24416 --- /dev/null +++ b/libs/infra-db/src/lib/repository/RatingRepository.ts @@ -0,0 +1,11 @@ +import { InsertRating } from '../model/InsertRating'; +import { SelectRating } from '../model/SelectRating'; +import { ratings } from '../schema'; +import { BaseRepository } from './BaseRepository'; + +export class RatingRepository extends BaseRepository { + async createRating(data: InsertRating): Promise { + const [rating] = await this.db.insert(ratings).values(data).returning(); + return rating; + } +} diff --git a/libs/infra-db/src/lib/schema/games.ts b/libs/infra-db/src/lib/schema/games.ts index 2c7bee1e..e84aed2d 100644 --- a/libs/infra-db/src/lib/schema/games.ts +++ b/libs/infra-db/src/lib/schema/games.ts @@ -1,7 +1,9 @@ import { relations, sql } from 'drizzle-orm'; import { + AnyPgColumn, boolean, check, + integer, jsonb, pgTable, text, @@ -12,6 +14,7 @@ import { import { TimeControlJsonB } from '../model/TimeControlJsonB'; import { actions } from './actions'; import { moves } from './moves'; +import { ratings } from './ratings'; import { gameStatusEnum } from './shared/gameStatusEnum'; import { resultEnum } from './shared/resultEnum'; import { resultReasonEnum } from './shared/resultReasonEnum'; @@ -33,6 +36,18 @@ export const games = pgTable( blackPlayerId: text('black_player_id').references(() => users.id, { onDelete: 'set null', }), + whiteRatingId: integer('white_rating_id').references( + (): AnyPgColumn => ratings.id, + { + onDelete: 'set null', + }, + ), + blackRatingId: integer('black_rating_id').references( + (): AnyPgColumn => ratings.id, + { + onDelete: 'set null', + }, + ), timeControlClassification: timeControlClassificationEnum( 'time_control_classification', ) @@ -97,4 +112,14 @@ export const gamesRelations = relations(games, ({ many, one }) => ({ fields: [games.blackPlayerId], references: [users.id], }), + whiteRating: one(ratings, { + fields: [games.whiteRatingId], + references: [ratings.id], + relationName: 'whiteRating', + }), + blackRating: one(ratings, { + fields: [games.blackRatingId], + references: [ratings.id], + relationName: 'blackRating', + }), })); diff --git a/libs/infra-db/src/lib/schema/ratings.ts b/libs/infra-db/src/lib/schema/ratings.ts index 04e98b2b..f12b9921 100644 --- a/libs/infra-db/src/lib/schema/ratings.ts +++ b/libs/infra-db/src/lib/schema/ratings.ts @@ -13,6 +13,7 @@ import { games } from './games'; import { timeControlClassificationEnum } from './shared/timeControlClassificationEnum'; import { variantEnum } from './shared/variantEnum'; import { users } from './users'; + export const ratings = pgTable( 'ratings', { From a2bf504bcea8ea50595b54f7759fa14591290562 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Thu, 6 Nov 2025 22:46:30 +0100 Subject: [PATCH 10/22] Add rating reference to game when joining --- .../lib/socket/__tests__/SocketRouter.spec.ts | 1 + libs/api-service/src/lib/Api.ts | 3 ++ .../src/lib/games/mapper/GameDetailsMapper.ts | 2 + .../src/lib/games/service/GamesService.ts | 14 +++++- .../src/lib/user/service/RatingsService.ts | 33 ++++++++++++++ libs/core-game/src/index.ts | 2 + libs/core-game/src/lib/model/GameMeta.ts | 3 +- .../src/lib/model/GameVariantType.ts | 1 + libs/core-game/src/lib/model/PlayerInfo.ts | 3 ++ .../src/lib/rating/model/GameRating.ts | 11 +++++ libs/core-rating/src/index.ts | 3 +- libs/core-rating/src/lib/GlickoTwo.ts | 30 +++++-------- .../src/lib/__tests__/GlickoTwo.spec.ts | 20 ++++----- libs/core-rating/src/lib/model/GameResult.ts | 4 +- libs/core-rating/src/lib/model/Player.ts | 5 --- libs/core-rating/src/lib/model/Rating.ts | 14 ++++++ .../src/lib/model/RatingProfile.ts | 5 --- .../src/lib/model/RatingSnapshot.ts | 6 +++ libs/infra-db/src/index.ts | 1 + libs/infra-db/src/lib/Repositories.ts | 3 ++ .../src/lib/repository/RatingRepository.ts | 45 ++++++++++++++++++- 21 files changed, 164 insertions(+), 45 deletions(-) create mode 100644 libs/api-service/src/lib/user/service/RatingsService.ts create mode 100644 libs/core-game/src/lib/model/GameVariantType.ts create mode 100644 libs/core-game/src/lib/rating/model/GameRating.ts delete mode 100644 libs/core-rating/src/lib/model/Player.ts create mode 100644 libs/core-rating/src/lib/model/Rating.ts delete mode 100644 libs/core-rating/src/lib/model/RatingProfile.ts create mode 100644 libs/core-rating/src/lib/model/RatingSnapshot.ts diff --git a/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts b/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts index bebce027..fe185766 100644 --- a/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts +++ b/libs/api-router/src/lib/socket/__tests__/SocketRouter.spec.ts @@ -27,6 +27,7 @@ const apiMock: Api = { {} as never, {} as never, {} as never, + {} as never, ), auth: new AuthService({} as never, {} as never, {} as never, { google: { clientId: '', clientSecret: '' }, diff --git a/libs/api-service/src/lib/Api.ts b/libs/api-service/src/lib/Api.ts index 96e91fea..0333fadf 100644 --- a/libs/api-service/src/lib/Api.ts +++ b/libs/api-service/src/lib/Api.ts @@ -7,6 +7,7 @@ import { AuthService } from './auth/service/AuthService'; import { GamesService } from './games/service/GamesService'; import { LockService } from './lock/service/LockService'; import { UsageMetricsService } from './metrics/UsageMetricsService'; +import { RatingsService } from './user/service/RatingsService'; export type Api = { games: GamesService; @@ -21,12 +22,14 @@ const from = ( authConfig: AuthConfig, ): Api => { const processId = randomUUID(); + const ratingsService = new RatingsService(repos.rating); const lockService = new LockService(repos.cache.client); const gamesService = new GamesService( repos.game, repos.move, repos.action, repos.cache, + ratingsService, lockService, ); const authService = new AuthService( diff --git a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts b/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts index a1f34541..d194c6d6 100644 --- a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts +++ b/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts @@ -293,6 +293,8 @@ export const GameDetailsMapper = { status: TO_STATUS_TYPE_MAPPING[game.status], startedAt: game.startedAt ?? null, endedAt: game.endedAt ?? null, + blackRatingId: game.players.black?.rating?.id ?? null, + whiteRatingId: game.players.white?.rating?.id ?? null, result: game.result ? FROM_RESULT_TYPE_MAPPING[game.result.type] : '0-0', }; }, diff --git a/libs/api-service/src/lib/games/service/GamesService.ts b/libs/api-service/src/lib/games/service/GamesService.ts index ac7b7f74..d99982bd 100644 --- a/libs/api-service/src/lib/games/service/GamesService.ts +++ b/libs/api-service/src/lib/games/service/GamesService.ts @@ -31,6 +31,7 @@ import { Job, Queue, Worker } from 'bullmq'; import { Session } from '../../auth/model/Session'; import { LockService } from '../../lock/service/LockService'; import { PageResponseMapper } from '../../mapper/PageResponseMapper'; +import { RatingsService } from '../../user/service/RatingsService'; import { GameDetailsMapper } from '../mapper/GameDetailsMapper'; type TimeControlJobData = { @@ -50,6 +51,7 @@ export class GamesService { private moveRepository: MoveRepository, private actionRepository: ActionRepository, private cacheRepo: CacheRepository, + private ratingsService: RatingsService, private lockService: LockService, ) { const connectionOptions = { connection: this.cacheRepo.client }; @@ -274,8 +276,18 @@ export class GamesService { }); } + const gameRating = await this.ratingsService.getRatingByPlayerId( + session.userId, + gameState.variant, + gameState.timeControl.classification, + ); + const updatedGame = chessGame.joinGame( - { id: session.userId, name: session.name ?? 'Anonymous' }, + { + id: session.userId, + name: session.name ?? 'Anonymous', + rating: gameRating, + }, data.side, ); const updatedGameState = updatedGame.getState(); diff --git a/libs/api-service/src/lib/user/service/RatingsService.ts b/libs/api-service/src/lib/user/service/RatingsService.ts new file mode 100644 index 00000000..b568e4df --- /dev/null +++ b/libs/api-service/src/lib/user/service/RatingsService.ts @@ -0,0 +1,33 @@ +import { GameVariantType, TimeControlClassification } from '@michess/core-game'; +import { Rating } from '@michess/core-rating'; +import { RatingRepository } from '@michess/infra-db'; + +export class RatingsService { + constructor(private readonly repositories: RatingRepository) {} + + getRatingByPlayerId( + playerId: string, + variant: GameVariantType, + timeControl: TimeControlClassification, + ) { + const rating = this.repositories.getRatingByPlayerId( + playerId, + variant, + timeControl, + ); + if (rating) { + return rating; + } else { + const defaultRating = Rating.default(); + return this.repositories.createRating({ + playerId, + variant, + timeControlClassification: timeControl, + rating: defaultRating.value, + deviation: defaultRating.deviation, + volatility: defaultRating.volatility, + timestamp: new Date(), + }); + } + } +} diff --git a/libs/core-game/src/index.ts b/libs/core-game/src/index.ts index 757c7f91..3cb5ccf8 100644 --- a/libs/core-game/src/index.ts +++ b/libs/core-game/src/index.ts @@ -14,5 +14,7 @@ export * from './lib/model/GameMeta'; export * from './lib/model/GamePlayers'; export * from './lib/model/GameState'; export * from './lib/model/GameStatusType'; +export * from './lib/model/GameVariantType'; export * from './lib/model/PlayerInfo'; export * from './lib/model/TimeControlClassification'; +export * from './lib/rating/model/GameRating'; diff --git a/libs/core-game/src/lib/model/GameMeta.ts b/libs/core-game/src/lib/model/GameMeta.ts index 0c8ee85d..50fb6efc 100644 --- a/libs/core-game/src/lib/model/GameMeta.ts +++ b/libs/core-game/src/lib/model/GameMeta.ts @@ -1,8 +1,9 @@ import { Maybe } from '@michess/common-utils'; +import { GameVariantType } from './GameVariantType'; export type GameMeta = { id: string; - variant: string; + variant: GameVariantType; isPrivate: boolean; startedAt: Maybe; endedAt: Maybe; diff --git a/libs/core-game/src/lib/model/GameVariantType.ts b/libs/core-game/src/lib/model/GameVariantType.ts new file mode 100644 index 00000000..90af86b0 --- /dev/null +++ b/libs/core-game/src/lib/model/GameVariantType.ts @@ -0,0 +1 @@ +export type GameVariantType = 'standard'; diff --git a/libs/core-game/src/lib/model/PlayerInfo.ts b/libs/core-game/src/lib/model/PlayerInfo.ts index 359d2a9c..c5f6e551 100644 --- a/libs/core-game/src/lib/model/PlayerInfo.ts +++ b/libs/core-game/src/lib/model/PlayerInfo.ts @@ -1,4 +1,7 @@ +import { RatingSnapshot } from '@michess/core-rating'; + export type PlayerInfo = { id: string; + rating?: RatingSnapshot; name: string; }; diff --git a/libs/core-game/src/lib/rating/model/GameRating.ts b/libs/core-game/src/lib/rating/model/GameRating.ts new file mode 100644 index 00000000..b63c501b --- /dev/null +++ b/libs/core-game/src/lib/rating/model/GameRating.ts @@ -0,0 +1,11 @@ +import { Maybe } from '@michess/common-utils'; +import { RatingSnapshot } from '@michess/core-rating'; +import { GameVariantType } from '../../model/GameVariantType'; +import { TimeControlClassification } from '../../model/TimeControlClassification'; + +export type GameRating = RatingSnapshot & { + playerId: string; + gameId: Maybe; + timeControlClassification: TimeControlClassification; + variant: GameVariantType; +}; diff --git a/libs/core-rating/src/index.ts b/libs/core-rating/src/index.ts index c3552ed7..05943a24 100644 --- a/libs/core-rating/src/index.ts +++ b/libs/core-rating/src/index.ts @@ -1 +1,2 @@ -export * from './lib/core-rating'; +export * from './lib/model/Rating'; +export * from './lib/model/RatingSnapshot'; diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts index 1960b6a8..61b98d71 100644 --- a/libs/core-rating/src/lib/GlickoTwo.ts +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -1,14 +1,10 @@ import { Maybe } from '@michess/common-utils'; import { GameResult } from './model/GameResult'; -import { RatingProfile } from './model/RatingProfile'; +import { Rating } from './model/Rating'; const TAO = 0.5; const GLICKO_SCALE_DENOMINATOR = 173.7178; -const DEFAULT_PLAYER: RatingProfile = { - rating: 1500, - deviation: 350, - volatility: 0.06, -}; + const CONVERGENCE_EPSILON = 0.000001; const convertRatingToGlickoScale = (rating: number) => { @@ -17,11 +13,9 @@ const convertRatingToGlickoScale = (rating: number) => { const convertDeviationToGlickoScale = (deviation: number) => { return deviation / GLICKO_SCALE_DENOMINATOR; }; -const convertToGlickoScale = ( - player: RatingProfile, -): { phi: number; mu: number } => { +const convertToGlickoScale = (player: Rating): { phi: number; mu: number } => { return { - mu: convertRatingToGlickoScale(player.rating), + mu: convertRatingToGlickoScale(player.value), phi: convertDeviationToGlickoScale(player.deviation), }; }; @@ -90,22 +84,22 @@ const updateRatingDeviation = ( }; const algorithm = ( - player: Maybe, + rating: Maybe, gameResults: GameResult[], elapsedPeriodsSinceLastUpdate = 1, -): RatingProfile => { - const actualPlayer = player || DEFAULT_PLAYER; +): Rating => { + const actualRating = rating || Rating.default(); // Step 2: Convert to Glicko-2 scale - const { phi, mu } = convertToGlickoScale(actualPlayer); - const sigma = actualPlayer.volatility; + const { phi, mu } = convertToGlickoScale(actualRating); + const sigma = actualRating.volatility; if (gameResults.length === 0) { return { - rating: actualPlayer.rating, + value: actualRating.value, deviation: convertDeviationFromGlickoScale( updateRatingDeviation(phi, sigma, elapsedPeriodsSinceLastUpdate), ), - volatility: actualPlayer.volatility, + volatility: actualRating.volatility, }; } @@ -181,7 +175,7 @@ const algorithm = ( ); return { - rating: newRating, + value: newRating, deviation: newDeviation, volatility: newSigma, }; diff --git a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts index 8866a8a3..1de3b23c 100644 --- a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts +++ b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts @@ -1,21 +1,21 @@ import { GlickoTwo } from '../GlickoTwo'; -import { RatingProfile } from '../model/RatingProfile'; +import { Rating } from '../model/Rating'; describe('GlickoTwo', () => { it('should handle the example calculation from the report', () => { - const player: RatingProfile = { - rating: 1500, + const player: Rating = { + value: 1500, deviation: 200, volatility: 0.06, }; const result = GlickoTwo.algorithm(player, [ - { opponent: { rating: 1400, deviation: 30, volatility: 0.06 }, score: 1 }, + { opponent: { value: 1400, deviation: 30, volatility: 0.06 }, score: 1 }, { - opponent: { rating: 1550, deviation: 100, volatility: 0.06 }, + opponent: { value: 1550, deviation: 100, volatility: 0.06 }, score: 0, }, { - opponent: { rating: 1700, deviation: 300, volatility: 0.06 }, + opponent: { value: 1700, deviation: 300, volatility: 0.06 }, score: 0, }, ]); @@ -23,20 +23,20 @@ describe('GlickoTwo', () => { // Paper results are 1464.06, but it is not correct due to // rounding in the intermediate steps: // https://github.com/andriykuba/scala-glicko2?tab=readme-ov-file#precision - expect(result.rating.toFixed(2)).toBe('1464.05'); + expect(result.value.toFixed(2)).toBe('1464.05'); expect(result.deviation.toFixed(2)).toBe('151.52'); expect(result.volatility).toBeCloseTo(0.05999, 4); }); it('should handle a player with no games played', () => { - const player: RatingProfile = { - rating: 1500, + const player: Rating = { + value: 1500, deviation: 60, volatility: 0.06, }; const result = GlickoTwo.algorithm(player, [], 50); - expect(result.rating).toBe(1500); + expect(result.value).toBe(1500); expect(result.deviation).toBeCloseTo(95, 1); expect(result.volatility).toBe(0.06); }); diff --git a/libs/core-rating/src/lib/model/GameResult.ts b/libs/core-rating/src/lib/model/GameResult.ts index b13c27e3..9c393ca2 100644 --- a/libs/core-rating/src/lib/model/GameResult.ts +++ b/libs/core-rating/src/lib/model/GameResult.ts @@ -1,6 +1,6 @@ -import { RatingProfile } from './RatingProfile'; +import { Rating } from './Rating'; export type GameResult = { - opponent: RatingProfile; + opponent: Rating; score: number; // 1 = win, 0.5 = draw, 0 = loss }; diff --git a/libs/core-rating/src/lib/model/Player.ts b/libs/core-rating/src/lib/model/Player.ts deleted file mode 100644 index 0fa02c8c..00000000 --- a/libs/core-rating/src/lib/model/Player.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { RatingProfile } from './RatingProfile'; - -export type Player = RatingProfile & { - timestamp: number; -}; diff --git a/libs/core-rating/src/lib/model/Rating.ts b/libs/core-rating/src/lib/model/Rating.ts new file mode 100644 index 00000000..b6b58cb5 --- /dev/null +++ b/libs/core-rating/src/lib/model/Rating.ts @@ -0,0 +1,14 @@ +export type Rating = { + value: number; + deviation: number; + volatility: number; +}; + +const DEFAULT_RATING: Rating = { + value: 1500, + deviation: 350, + volatility: 0.06, +}; +export const Rating = { + default: (): Rating => DEFAULT_RATING, +}; diff --git a/libs/core-rating/src/lib/model/RatingProfile.ts b/libs/core-rating/src/lib/model/RatingProfile.ts deleted file mode 100644 index fab410d9..00000000 --- a/libs/core-rating/src/lib/model/RatingProfile.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type RatingProfile = { - rating: number; - deviation: number; - volatility: number; -}; diff --git a/libs/core-rating/src/lib/model/RatingSnapshot.ts b/libs/core-rating/src/lib/model/RatingSnapshot.ts new file mode 100644 index 00000000..a6ffb775 --- /dev/null +++ b/libs/core-rating/src/lib/model/RatingSnapshot.ts @@ -0,0 +1,6 @@ +import { Rating } from './Rating'; + +export type RatingSnapshot = Rating & { + id: number; + timestamp: Date; +}; diff --git a/libs/infra-db/src/index.ts b/libs/infra-db/src/index.ts index 81fd732d..d1a19796 100644 --- a/libs/infra-db/src/index.ts +++ b/libs/infra-db/src/index.ts @@ -9,5 +9,6 @@ export * from './lib/repository/ActionRepository'; export * from './lib/repository/CacheRepository'; export * from './lib/repository/GameRepository'; export * from './lib/repository/MoveRepository'; +export * from './lib/repository/RatingRepository'; export * from './lib/repository/UserRepository'; export * as schema from './lib/schema'; diff --git a/libs/infra-db/src/lib/Repositories.ts b/libs/infra-db/src/lib/Repositories.ts index 1605fb51..c21fc633 100644 --- a/libs/infra-db/src/lib/Repositories.ts +++ b/libs/infra-db/src/lib/Repositories.ts @@ -4,6 +4,7 @@ import { ActionRepository } from './repository/ActionRepository'; import { CacheRepository } from './repository/CacheRepository'; import { GameRepository } from './repository/GameRepository'; import { MoveRepository } from './repository/MoveRepository'; +import { RatingRepository } from './repository/RatingRepository'; import { UserRepository } from './repository/UserRepository'; export type Repositories = { @@ -12,6 +13,7 @@ export type Repositories = { move: MoveRepository; action: ActionRepository; cache: CacheRepository; + rating: RatingRepository; }; const from = (sql: Sql, redis: Redis): Repositories => { @@ -21,6 +23,7 @@ const from = (sql: Sql, redis: Redis): Repositories => { move: new MoveRepository(sql), action: new ActionRepository(sql), cache: new CacheRepository(redis), + rating: new RatingRepository(sql), }; }; diff --git a/libs/infra-db/src/lib/repository/RatingRepository.ts b/libs/infra-db/src/lib/repository/RatingRepository.ts index a4a24416..6b1f69c5 100644 --- a/libs/infra-db/src/lib/repository/RatingRepository.ts +++ b/libs/infra-db/src/lib/repository/RatingRepository.ts @@ -1,11 +1,52 @@ +import { Maybe } from '@michess/common-utils'; +import { + GameRating, + GameVariantType, + TimeControlClassification, +} from '@michess/core-game'; +import { and, eq } from 'drizzle-orm'; import { InsertRating } from '../model/InsertRating'; import { SelectRating } from '../model/SelectRating'; import { ratings } from '../schema'; import { BaseRepository } from './BaseRepository'; export class RatingRepository extends BaseRepository { - async createRating(data: InsertRating): Promise { + toGameRating(rating: SelectRating): GameRating { + return { + id: rating.id, + playerId: rating.playerId, + gameId: rating.gameId ?? undefined, + value: rating.rating, + deviation: rating.deviation, + volatility: rating.volatility, + timestamp: rating.timestamp, + timeControlClassification: + rating.timeControlClassification as TimeControlClassification, + variant: rating.variant as GameVariantType, + }; + } + + async createRating(data: InsertRating): Promise { const [rating] = await this.db.insert(ratings).values(data).returning(); - return rating; + return this.toGameRating(rating); + } + + async getRatingByPlayerId( + playerId: string, + variant: GameVariantType, + timeControl: TimeControlClassification, + ): Promise> { + const rating = await this.db.query.ratings.findFirst({ + where: and( + eq(ratings.playerId, playerId), + eq(ratings.variant, variant), + eq(ratings.timeControlClassification, timeControl), + ), + }); + if (rating) { + return this.toGameRating(rating); + } else { + return undefined; + } } } From dff7978da25a9453fcd935d96a5018e91f38bc51 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sat, 8 Nov 2025 20:10:34 +0100 Subject: [PATCH 11/22] Add indicative player rating diff on game state --- libs/api-schema/src/index.ts | 1 + .../src/lib/player/PlayerInfoV1Schema.ts | 2 + .../src/lib/games/mapper/GameDetailsMapper.ts | 27 +- .../src/lib/games/mapper/GameMapper.ts | 312 ++++++++++++++++++ .../src/lib/games/service/GamesService.ts | 50 +-- libs/core-game/src/index.ts | 4 + libs/core-game/src/lib/ChessGame.ts | 76 ++++- .../src/lib/__tests__/ChessGame.spec.ts | 1 + libs/core-game/src/lib/model/ChessGameIn.ts | 38 +++ .../src/lib/model/ChessGameResult.ts | 12 + libs/core-game/src/lib/model/GameMeta.ts | 1 - libs/core-game/src/lib/model/GamePlayers.ts | 117 +++++++ libs/core-game/src/lib/model/GameState.ts | 2 - libs/core-game/src/lib/model/PlayerInfo.ts | 1 + libs/core-game/src/lib/model/TimeControlIn.ts | 6 + .../src/lib/model/__mocks__/GameState.mock.ts | 1 - libs/core-rating/src/index.ts | 2 + libs/core-rating/src/lib/GlickoTwo.ts | 6 +- libs/core-rating/src/lib/RatingCalculator.ts | 35 ++ .../src/lib/__tests__/GlickoTwo.spec.ts | 6 +- libs/core-rating/src/lib/model/GameResult.ts | 7 +- libs/core-rating/src/lib/model/Score.ts | 6 + .../src/lib/model/SelectGameWithRelations.ts | 9 +- .../src/lib/repository/GameRepository.ts | 4 + 24 files changed, 662 insertions(+), 64 deletions(-) create mode 100644 libs/api-service/src/lib/games/mapper/GameMapper.ts create mode 100644 libs/core-game/src/lib/model/ChessGameIn.ts create mode 100644 libs/core-game/src/lib/model/TimeControlIn.ts create mode 100644 libs/core-rating/src/lib/RatingCalculator.ts create mode 100644 libs/core-rating/src/lib/model/Score.ts diff --git a/libs/api-schema/src/index.ts b/libs/api-schema/src/index.ts index 4f4d5c24..8ed63dff 100644 --- a/libs/api-schema/src/index.ts +++ b/libs/api-schema/src/index.ts @@ -36,4 +36,5 @@ export * from './lib/player/PlayerGameInfoPageResponseV1'; export * from './lib/player/PlayerGameInfoQueryV1'; export * from './lib/player/PlayerGameInfoQueryV1Schema'; export * from './lib/player/PlayerGameInfoV1'; +export * from './lib/player/PlayerInfoV1'; export * from './lib/ServerToClientEvents'; diff --git a/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts b/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts index efe00179..eeaff147 100644 --- a/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts +++ b/libs/api-schema/src/lib/player/PlayerInfoV1Schema.ts @@ -2,5 +2,7 @@ import { z } from 'zod'; export const PlayerInfoV1Schema = z.object({ id: z.string(), + rating: z.number().min(0).optional(), + ratingDiff: z.number().optional(), name: z.string().min(1).max(100), }); diff --git a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts b/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts index d194c6d6..2fb14c6e 100644 --- a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts +++ b/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts @@ -3,12 +3,14 @@ import { GameVariantV1, LobbyGameItemV1, PlayerGameInfoV1, + PlayerInfoV1, } from '@michess/api-schema'; import { isDefined, Maybe } from '@michess/common-utils'; import { ChessPosition, Color, Move } from '@michess/core-board'; import { ChessGameResult, ChessGameResultType, + ClockInstant, DrawReasonType, GameAction, GameActionOption, @@ -17,14 +19,13 @@ import { GamePlayers, GameStatusType, PlayerInfo, + TimeControl, } from '@michess/core-game'; import { InsertGame, SelectGame, SelectGameWithRelations, } from '@michess/infra-db'; -import { ClockInstant } from 'libs/core-game/src/lib/model/ClockInstant'; -import { TimeControl } from 'libs/core-game/src/lib/model/TimeControl'; const TO_RESULT_TYPE_MAPPING: Record< SelectGameWithRelations['result'], @@ -70,7 +71,6 @@ const toGameMeta = (game: SelectGameWithRelations | SelectGame): GameMeta => ({ isPrivate: game.isPrivate, createdAt: game.createdAt, startedAt: game.startedAt ?? undefined, - endedAt: game.endedAt ?? undefined, updatedAt: game.updatedAt, }); @@ -144,9 +144,11 @@ const toTimeControl = ({ const toChessGameResult = ({ result, + endedAt, }: SelectGame | SelectGameWithRelations): Maybe => { return result !== '0-0' ? { + timestamp: endedAt ? endedAt.getTime() : 0, type: TO_RESULT_TYPE_MAPPING[result], } : undefined; @@ -190,13 +192,22 @@ export const GameDetailsMapper = { }; }, + toPlayerInfoV1(player: PlayerInfo): PlayerInfoV1 { + return { + id: player.id, + name: player.name, + rating: player.rating?.value, + ratingDiff: player.ratingDiff, + }; + }, + toLobbyGameItemV1(game: GameDetails): LobbyGameItemV1 { return { id: game.id, opponent: game.players.white - ? game.players.white + ? this.toPlayerInfoV1(game.players.white) : game.players.black - ? game.players.black + ? this.toPlayerInfoV1(game.players.black) : { id: 'anon', name: 'Anonymous', @@ -259,12 +270,16 @@ export const GameDetailsMapper = { white: game.players.white ? { id: game.players.white.id, + rating: game.players.white.rating?.value, + ratingDiff: game.players.white.ratingDiff, name: game.players.white.name, } : undefined, black: game.players.black ? { id: game.players.black.id, + rating: game.players.black.rating?.value, + ratingDiff: game.players.black.ratingDiff, name: game.players.black.name, } : undefined, @@ -292,7 +307,7 @@ export const GameDetailsMapper = { blackPlayerId: game.players.black ? game.players.black.id : null, status: TO_STATUS_TYPE_MAPPING[game.status], startedAt: game.startedAt ?? null, - endedAt: game.endedAt ?? null, + endedAt: game.result?.timestamp ? new Date(game.result.timestamp) : null, blackRatingId: game.players.black?.rating?.id ?? null, whiteRatingId: game.players.white?.rating?.id ?? null, result: game.result ? FROM_RESULT_TYPE_MAPPING[game.result.type] : '0-0', diff --git a/libs/api-service/src/lib/games/mapper/GameMapper.ts b/libs/api-service/src/lib/games/mapper/GameMapper.ts new file mode 100644 index 00000000..36c7e830 --- /dev/null +++ b/libs/api-service/src/lib/games/mapper/GameMapper.ts @@ -0,0 +1,312 @@ +import { + GameDetailsV1, + GameVariantV1, + LobbyGameItemV1, + PlayerGameInfoV1, + PlayerInfoV1, +} from '@michess/api-schema'; +import { isDefined, Maybe } from '@michess/common-utils'; +import { Color, Move } from '@michess/core-board'; +import { + ChessGame, + ChessGameResult, + ChessGameResultType, + DrawReasonType, + GameAction, + GameMeta, + GamePlayers, + GameStatusType, + PlayerInfo, + TimeControlIn, +} from '@michess/core-game'; +import { + InsertGame, + SelectGame, + SelectGameWithRelations, +} from '@michess/infra-db'; +import z from 'zod'; + +const TO_RESULT_TYPE_MAPPING: Record< + SelectGameWithRelations['result'], + ChessGameResultType +> = { + '1-0': 'white_win', + '0-1': 'black_win', + '1/2-1/2': 'draw', + '0-0': 'draw', +}; + +const FROM_RESULT_TYPE_MAPPING: Record< + ChessGameResultType, + SelectGameWithRelations['result'] +> = { + white_win: '1-0', + black_win: '0-1', + draw: '1/2-1/2', +}; + +const FROM_STATUS_TYPE_MAPPING: Record< + SelectGameWithRelations['status'], + GameStatusType +> = { + empty: 'EMPTY', + ready: 'READY', + waiting: 'WAITING', + 'in-progress': 'IN_PROGRESS', + end: 'ENDED', +}; + +const TO_STATUS_TYPE_MAPPING: Record = { + EMPTY: 'empty', + READY: 'ready', + WAITING: 'waiting', + IN_PROGRESS: 'in-progress', + ENDED: 'end', +}; + +const toGameMeta = (game: SelectGameWithRelations | SelectGame): GameMeta => ({ + id: game.gameId, + variant: game.variant ?? 'standard', + isPrivate: game.isPrivate, + createdAt: game.createdAt, + startedAt: game.startedAt ?? undefined, + updatedAt: game.updatedAt, +}); + +const toPlayerInfo = (player: { + id: string; + name: string | null; + rating: SelectGameWithRelations['whiteRating']; +}): PlayerInfo => ({ + id: player.id, + name: player.name ?? 'Anonymous', + rating: player.rating + ? { + deviation: player.rating.deviation, + id: player.rating.id, + value: player.rating.rating, + timestamp: player.rating.timestamp, + volatility: player.rating.volatility, + } + : undefined, +}); + +const toGamePlayers = (game: SelectGameWithRelations): GamePlayers => ({ + white: game.whitePlayer + ? toPlayerInfo({ ...game.whitePlayer, rating: game.whiteRating }) + : undefined, + black: game.blackPlayer + ? toPlayerInfo({ ...game.blackPlayer, rating: game.blackRating }) + : undefined, +}); + +const toGameActions = (game: SelectGameWithRelations): GameAction[] => { + return game.actions + .map((action) => { + const base = { + color: action.color, + moveNumber: action.moveNumber, + }; + switch (action.type) { + case 'resign': + case 'offer_draw': { + return { ...base, type: action.type }; + } + case 'accept_draw': + return { + ...base, + type: action.type, + reason: + (action.payload?.reason as DrawReasonType) ?? 'by_agreement', + }; + } + }) + .filter(isDefined); +}; + +const toPlayerInfoV1 = (player: PlayerInfo): PlayerInfoV1 => { + return { + id: player.id, + name: player.name, + rating: player.rating?.value, + ratingDiff: player.ratingDiff, + }; +}; + +const toChessGameResult = ({ + result, + endedAt, +}: SelectGame | SelectGameWithRelations): Maybe => { + return result !== '0-0' + ? { + timestamp: endedAt ? endedAt.getTime() : 0, + type: TO_RESULT_TYPE_MAPPING[result], + } + : undefined; +}; + +const toTimeControl = (game: SelectGame): TimeControlIn => { + return { + classification: game.timeControlClassification, + daysPerMove: + z.object({ daysPerMove: z.number().min(0) }).safeParse(game.timeControl) + .data?.daysPerMove ?? 0, + incrementSec: + z.object({ increment: z.number().min(0) }).safeParse(game.timeControl) + .data?.increment ?? 0, + initialSec: + z.object({ initial: z.number().min(0) }).safeParse(game.timeControl).data + ?.initial ?? 0, + }; +}; + +export const GameMapper = { + fromSelectGame(game: SelectGame): ChessGame { + return ChessGame.from({ + players: { + white: undefined, + black: undefined, + }, + timeControl: toTimeControl(game), + actionRecord: [], + result: toChessGameResult(game), + status: FROM_STATUS_TYPE_MAPPING[game.status], + initialPosition: undefined, + movesRecord: [], + ...toGameMeta(game), + }); + }, + + fromSelectGameWithRelations(game: SelectGameWithRelations): ChessGame { + const players = toGamePlayers(game); + return ChessGame.from({ + ...toGameMeta(game), + timeControl: toTimeControl(game), + actionRecord: toGameActions(game), + players, + status: FROM_STATUS_TYPE_MAPPING[game.status], + variant: game.variant ?? 'standard', + isPrivate: game.isPrivate, + initialPosition: undefined, + result: toChessGameResult(game), + movesRecord: game.moves.map((move) => ({ + ...Move.fromUci(move.uci), + timestamp: move.movedAt.getTime(), + })), + }); + }, + + toLobbyGameItemV1(game: ChessGame): LobbyGameItemV1 { + const gameState = game.getState(); + return { + id: gameState.id, + opponent: gameState.players.white + ? toPlayerInfoV1(gameState.players.white) + : gameState.players.black + ? toPlayerInfoV1(gameState.players.black) + : { + id: 'anon', + name: 'Anonymous', + }, + variant: gameState.variant as GameVariantV1, + createdAt: gameState.createdAt.toISOString(), + availableColor: !gameState.players.white + ? 'white' + : !gameState.players.black + ? 'black' + : 'spectator', + }; + }, + toPlayerGameInfoV1(chessGame: ChessGame, playerId: string): PlayerGameInfoV1 { + const game = chessGame.getState(); + const ownSide = + game.players.white?.id === playerId + ? 'white' + : game.players.black?.id === playerId + ? 'black' + : 'white'; // Should not happen, but fail gracefully + const opponent: PlayerInfo = + ownSide === 'white' + ? (game.players.black ?? { id: 'anon', name: 'Anonymous' }) + : (game.players.white ?? { id: 'anon', name: 'Anonymous' }); + const initialTurn = game.initialPosition.turn; + return { + id: game.id, + opponent: { + id: opponent.id, + name: opponent.name, + }, + ownSide, + turn: + game.movesRecord.length % 2 === 0 + ? initialTurn + : Color.opposite(initialTurn), + variant: game.variant as GameVariantV1, + result: game.result + ? { + type: game.result.type, + } + : undefined, + }; + }, + toGameDetailsV1(chessGame: ChessGame, isSpectator?: boolean): GameDetailsV1 { + const game = chessGame.getState(); + const clock = chessGame.clock?.instant; + const availableActions = isSpectator + ? [] + : chessGame.getAdditionalActions(); + + return { + id: game.id, + status: game.status, + clock, + timeControl: game.timeControl, + players: { + white: game.players.white + ? { + id: game.players.white.id, + rating: game.players.white.rating?.value, + ratingDiff: game.players.white.ratingDiff, + name: game.players.white.name, + } + : undefined, + black: game.players.black + ? { + id: game.players.black.id, + rating: game.players.black.rating?.value, + ratingDiff: game.players.black.ratingDiff, + name: game.players.black.name, + } + : undefined, + }, + result: game.result + ? { + type: game.result.type, + } + : undefined, + variant: 'standard', + isPrivate: game.isPrivate, + actionOptions: availableActions ?? [], + moves: game.movesRecord.map((move) => ({ + uci: Move.toUci(move), + })), + initialPosition: undefined, + startedAt: game.startedAt ?? undefined, + }; + }, + + toInsertGame(chessGame: ChessGame): InsertGame { + const game = chessGame.getState(); + return { + isPrivate: game.isPrivate, + whitePlayerId: game.players.white ? game.players.white.id : null, + blackPlayerId: game.players.black ? game.players.black.id : null, + status: TO_STATUS_TYPE_MAPPING[game.status], + startedAt: game.startedAt ?? null, + endedAt: game.result?.timestamp ? new Date(game.result.timestamp) : null, + blackRatingId: game.players.black?.rating?.id ?? null, + whiteRatingId: game.players.white?.rating?.id ?? null, + result: game.result ? FROM_RESULT_TYPE_MAPPING[game.result.type] : '0-0', + }; + }, +}; diff --git a/libs/api-service/src/lib/games/service/GamesService.ts b/libs/api-service/src/lib/games/service/GamesService.ts index d99982bd..50ed8d29 100644 --- a/libs/api-service/src/lib/games/service/GamesService.ts +++ b/libs/api-service/src/lib/games/service/GamesService.ts @@ -33,6 +33,7 @@ import { LockService } from '../../lock/service/LockService'; import { PageResponseMapper } from '../../mapper/PageResponseMapper'; import { RatingsService } from '../../user/service/RatingsService'; import { GameDetailsMapper } from '../mapper/GameDetailsMapper'; +import { GameMapper } from '../mapper/GameMapper'; type TimeControlJobData = { gameId: string; @@ -90,17 +91,9 @@ export class GamesService { }> { const dbGame = await this.gameRepository.findGameWithRelationsById(gameId); assertDefined(dbGame, `Game '${gameId}' not found`); - const gameDetails = GameDetailsMapper.fromSelectGameWithRelations(dbGame); - const chessGame = ChessGame.fromGameState(gameDetails); - return { chessGame }; - } - private toGameDetailsV1(chessGame: ChessGame): GameDetailsV1 { - return GameDetailsMapper.toGameDetailsV1({ - game: chessGame.getState(), - clock: chessGame.clock?.instant, - availableActions: chessGame.getAdditionalActions(), - }); + const chessGame = GameMapper.fromSelectGameWithRelations(dbGame); + return { chessGame }; } private async updateFlagTimeoutJob(chessGame: ChessGame): Promise { @@ -196,10 +189,9 @@ export class GamesService { timeControlClassification, timeControl, }); - const chessGame = ChessGame.fromGameState( - GameDetailsMapper.fromSelectGame(createdGame), - ); - return this.toGameDetailsV1(chessGame); + + const chessGame = GameMapper.fromSelectGame(createdGame); + return GameMapper.toGameDetailsV1(chessGame); } async queryLobby(query: PaginationQueryV1): Promise { @@ -270,10 +262,7 @@ export class GamesService { const { chessGame } = await this.loadChessGame(data.gameId); const gameState = chessGame.getState(); if (data.side === 'spectator') { - return GameDetailsMapper.toGameDetailsV1({ - game: gameState, - clock: chessGame.clock?.instant, - }); + return GameMapper.toGameDetailsV1(chessGame, true); } const gameRating = await this.ratingsService.getRatingByPlayerId( @@ -295,7 +284,7 @@ export class GamesService { gameState.id, GameDetailsMapper.toInsertGame(updatedGameState), ); - return this.toGameDetailsV1(updatedGame); + return GameMapper.toGameDetailsV1(updatedGame); } async leaveGame( @@ -315,7 +304,7 @@ export class GamesService { chessGame.getState().id, GameDetailsMapper.toInsertGame(updatedGameState), ); - return this.toGameDetailsV1(updatedGame); + return GameMapper.toGameDetailsV1(updatedGame); } async makeMove( @@ -366,7 +355,7 @@ export class GamesService { if (gameStateUpdated) { return { - gameDetails: this.toGameDetailsV1(updatedGame), + gameDetails: GameMapper.toGameDetailsV1(updatedGame), move: moveMadeV1, }; } else { @@ -394,7 +383,7 @@ export class GamesService { const action = updatedGameState.actionRecord.at(-1); action && (await this.actionRepository.createAction(updatedGameState.id, action)); - return this.toGameDetailsV1(updatedGame); + return GameMapper.toGameDetailsV1(updatedGame); } async cleanupGames(): Promise { @@ -415,18 +404,11 @@ export class GamesService { const dbGame = await this.gameRepository.findGameWithRelationsById(gameId); assertDefined(dbGame, `Game '${gameId}' not found`); - const gameDetails = GameDetailsMapper.fromSelectGameWithRelations(dbGame); - const chessGame = ChessGame.fromGameState(gameDetails); - - const updatedGameState = chessGame.getState(); - const gameResultChanged = - updatedGameState.result?.type !== gameDetails.result?.type; + const chessGame = GameMapper.fromSelectGameWithRelations(dbGame); - if (gameResultChanged) { - await this.gameRepository.updateGame( - gameDetails.id, - GameDetailsMapper.toInsertGame(updatedGameState), - ); + const insertGame = GameMapper.toInsertGame(chessGame); + if (insertGame.result !== dbGame.result) { + await this.gameRepository.updateGame(chessGame.id, insertGame); return chessGame; } else { return undefined; @@ -435,7 +417,7 @@ export class GamesService { const chessGame = await handleFlagTimeoutWithLock(); if (chessGame) { - this.notifyObservers(this.toGameDetailsV1(chessGame)); + this.notifyObservers(GameMapper.toGameDetailsV1(chessGame)); } } } diff --git a/libs/core-game/src/index.ts b/libs/core-game/src/index.ts index 3cb5ccf8..3d78924c 100644 --- a/libs/core-game/src/index.ts +++ b/libs/core-game/src/index.ts @@ -7,8 +7,10 @@ export * from './lib/Chessboard'; export * from './lib/ChessGame'; export * from './lib/model/ChessGameError'; export * from './lib/model/ChessGameErrorCode'; +export * from './lib/model/ChessGameIn'; export * from './lib/model/ChessGameResult'; export * from './lib/model/ChessGameResultType'; +export * from './lib/model/ClockInstant'; export * from './lib/model/GameDetails'; export * from './lib/model/GameMeta'; export * from './lib/model/GamePlayers'; @@ -16,5 +18,7 @@ export * from './lib/model/GameState'; export * from './lib/model/GameStatusType'; export * from './lib/model/GameVariantType'; export * from './lib/model/PlayerInfo'; +export * from './lib/model/TimeControl'; export * from './lib/model/TimeControlClassification'; +export * from './lib/model/TimeControlIn'; export * from './lib/rating/model/GameRating'; diff --git a/libs/core-game/src/lib/ChessGame.ts b/libs/core-game/src/lib/ChessGame.ts index 3b5a83e9..36a69130 100644 --- a/libs/core-game/src/lib/ChessGame.ts +++ b/libs/core-game/src/lib/ChessGame.ts @@ -1,11 +1,18 @@ -import { isDefined, Maybe } from '@michess/common-utils'; -import { ChessPosition, Color, Move } from '@michess/core-board'; +import { assertDefined, isDefined, Maybe } from '@michess/common-utils'; +import { + ChessPosition, + Color, + FenParser, + FenStr, + Move, +} from '@michess/core-board'; import { ChessGameActions } from './actions/ChessGameActions'; import { GameActionIn } from './actions/model/GameActionIn'; import { GameActionOption } from './actions/model/GameActionOption'; import { Chessboard } from './Chessboard'; import { ChessClock } from './ChessClock'; import { ChessGameError } from './model/ChessGameError'; +import { ChessGameIn } from './model/ChessGameIn'; import { ChessGameResult } from './model/ChessGameResult'; import { GameMeta } from './model/GameMeta'; import { GamePlayers } from './model/GamePlayers'; @@ -38,22 +45,21 @@ export type ChessGame = { hasNewStatus(oldChess: ChessGame): boolean; hasNewActionOptions(oldChess: ChessGame): boolean; clock: Maybe; + id: string; }; const endGame = ( gameState: GameStateInternal, result: ChessGameResult, ): GameStateInternal => { - const endedAt = new Date(); const resultToSet = gameState.result ?? result; - const pausedClock = gameState.clock?.pause(endedAt.getTime()); + const pausedClock = gameState.clock?.pause(resultToSet.timestamp); return { ...gameState, status: 'ENDED', meta: { ...gameState.meta, - endedAt, }, result: resultToSet, clock: pausedClock, @@ -175,7 +181,7 @@ const evalResult = ( board.position.turn === Color.White ? Color.Black : Color.White, ); } else if (board.isStalemate || board.isInsufficientMaterial) { - return { type: 'draw' }; + return ChessGameResult.toDraw(); } else if (flagResult) { return flagResult; } else { @@ -244,7 +250,6 @@ const fromGameStateInternal = ( : gameStateInternal.status; const startedAt = gameStateInternal.meta.startedAt ?? new Date(); - const endedAt = shouldEndGame ? new Date() : gameStateInternal.meta.endedAt; return fromGameStateInternal({ ...gameStateInternal, @@ -254,13 +259,15 @@ const fromGameStateInternal = ( meta: { ...gameStateInternal.meta, startedAt, - endedAt, }, result: gameResult, additionalActions: additionalActions.updateBoard(newStatus, newBoard), }); }; return { + get id(): string { + return gameStateInternal.meta.id; + }, get clock(): Maybe { return gameStateInternal.clock; }, @@ -375,7 +382,58 @@ const fromGameState = (gameState: GameState): ChessGame => { }); }; +const from = (chessGameIn: ChessGameIn): ChessGame => { + const timeControl: TimeControl = (() => { + switch (chessGameIn.timeControl.classification) { + case 'bullet': + case 'blitz': + case 'rapid': { + assertDefined(chessGameIn.timeControl.initialSec); + assertDefined(chessGameIn.timeControl.incrementSec); + return { + classification: chessGameIn.timeControl.classification, + initialSec: chessGameIn.timeControl.initialSec, + incrementSec: chessGameIn.timeControl.incrementSec, + }; + } + case 'correspondence': { + assertDefined(chessGameIn.timeControl.daysPerMove); + return { + classification: 'correspondence', + daysPerMove: chessGameIn.timeControl.daysPerMove, + }; + } + case 'no_clock': + default: + return { + classification: 'no_clock', + }; + } + })(); + return fromGameState({ + id: chessGameIn.id, + createdAt: chessGameIn.createdAt, + updatedAt: chessGameIn.updatedAt, + startedAt: chessGameIn.startedAt, + isPrivate: chessGameIn.isPrivate, + variant: chessGameIn.variant, + players: GamePlayers.from(chessGameIn.players), + status: chessGameIn.status, + result: chessGameIn.result, + resultStr: ChessGameResult.toResultString(chessGameIn.result), + initialPosition: FenParser.toChessPosition( + chessGameIn.initialPosition ?? FenStr.standardInitial(), + ), + actionRecord: chessGameIn.actionRecord, + movesRecord: chessGameIn.movesRecord, + timeControl, + }); +}; + export const ChessGame = { - fromChessPosition, + /** For testing */ fromGameState, + + fromChessPosition, + from, }; diff --git a/libs/core-game/src/lib/__tests__/ChessGame.spec.ts b/libs/core-game/src/lib/__tests__/ChessGame.spec.ts index 07888a6c..2e527254 100644 --- a/libs/core-game/src/lib/__tests__/ChessGame.spec.ts +++ b/libs/core-game/src/lib/__tests__/ChessGame.spec.ts @@ -141,6 +141,7 @@ describe('ChessGame', () => { const chessGame = ChessGame.fromChessPosition(position).setResult({ type: 'white_win', + timestamp: Date.now(), }); const actions = chessGame.getAdditionalActions(); diff --git a/libs/core-game/src/lib/model/ChessGameIn.ts b/libs/core-game/src/lib/model/ChessGameIn.ts new file mode 100644 index 00000000..7377b44b --- /dev/null +++ b/libs/core-game/src/lib/model/ChessGameIn.ts @@ -0,0 +1,38 @@ +// export type GameState = GameMeta & { +// players: GamePlayers; +// status: GameStatusType; +// result: Maybe; +// resultStr: string; +// initialPosition: ChessPosition; +// actionRecord: GameAction[]; +// movesRecord: MoveRecord[]; +// timeControl: TimeControl; +// }; + +import { Maybe } from '@michess/common-utils'; +import { FenStr, MoveRecord } from '@michess/core-board'; +import { RatingSnapshot } from '@michess/core-rating'; +import { GameAction } from '../actions/model/GameAction'; +import { ChessGameResult } from './ChessGameResult'; +import { GameMeta } from './GameMeta'; +import { GameStatusType } from './GameStatusType'; +import { TimeControlIn } from './TimeControlIn'; + +type Player = { + id: string; + name: string; + rating?: RatingSnapshot; +}; + +export type ChessGameIn = GameMeta & { + players: { + white: Maybe; + black: Maybe; + }; + status: GameStatusType; + result: Maybe; + initialPosition: Maybe; + actionRecord: GameAction[]; + movesRecord: MoveRecord[]; + timeControl: TimeControlIn; +}; diff --git a/libs/core-game/src/lib/model/ChessGameResult.ts b/libs/core-game/src/lib/model/ChessGameResult.ts index 1900bc55..73ec7c44 100644 --- a/libs/core-game/src/lib/model/ChessGameResult.ts +++ b/libs/core-game/src/lib/model/ChessGameResult.ts @@ -5,6 +5,7 @@ import { ChessGameResultType } from './ChessGameResultType'; import { ClockInstant } from './ClockInstant'; export type ChessGameResult = { + timestamp: number; type: ChessGameResultType; // reason?: 'resignation' | 'stalemate' | 'threefold_repetition' | 'fifty_moves'; }; @@ -18,10 +19,12 @@ export const ChessGameResult = { case 'accept_draw': return { type: 'draw', + timestamp: Date.now(), }; case 'resign': return { type: turn === Color.White ? 'white_win' : 'black_win', + timestamp: Date.now(), }; default: return undefined; @@ -46,16 +49,25 @@ export const ChessGameResult = { toCheckmate: (winner: Color): ChessGameResult => { return { type: winner === Color.White ? 'white_win' : 'black_win', + timestamp: Date.now(), + }; + }, + toDraw: (): ChessGameResult => { + return { + type: 'draw', + timestamp: Date.now(), }; }, toFlag: (instant: ClockInstant): Maybe => { if (instant.blackMs === 0) { return { type: 'white_win', + timestamp: Date.now(), }; } else if (instant.whiteMs === 0) { return { type: 'black_win', + timestamp: Date.now(), }; } else { return undefined; diff --git a/libs/core-game/src/lib/model/GameMeta.ts b/libs/core-game/src/lib/model/GameMeta.ts index 50fb6efc..8e6a48de 100644 --- a/libs/core-game/src/lib/model/GameMeta.ts +++ b/libs/core-game/src/lib/model/GameMeta.ts @@ -6,7 +6,6 @@ export type GameMeta = { variant: GameVariantType; isPrivate: boolean; startedAt: Maybe; - endedAt: Maybe; createdAt: Date; updatedAt: Date; }; diff --git a/libs/core-game/src/lib/model/GamePlayers.ts b/libs/core-game/src/lib/model/GamePlayers.ts index 8aa99e67..f8252745 100644 --- a/libs/core-game/src/lib/model/GamePlayers.ts +++ b/libs/core-game/src/lib/model/GamePlayers.ts @@ -1,5 +1,11 @@ import { Maybe } from '@michess/common-utils'; import { Color } from '@michess/core-board'; +import { + GameResult, + RatingCalculator, + RatingSnapshot, +} from '@michess/core-rating'; +import { ChessGameResult } from './ChessGameResult'; import { PlayerInfo } from './PlayerInfo'; export type GamePlayers = { @@ -7,7 +13,118 @@ export type GamePlayers = { black: Maybe; }; +const getGameResult = ( + result: ChessGameResult, + color: Color, + byColor: { + white: RatingSnapshot; + black: RatingSnapshot; + }, +): GameResult => { + const opponent = byColor[Color.opposite(color)]; + const timestamp = new Date(result.timestamp); + + const score = (() => { + switch (result.type) { + case 'white_win': + return color === Color.White ? 1 : 0; + case 'black_win': + return color === Color.Black ? 1 : 0; + case 'draw': + return 0.5; + } + })(); + + return { opponent, value: score, timestamp }; +}; + +const fromResult = ( + byColor: { + white: { id: string; name: string; rating: RatingSnapshot }; + black: { id: string; name: string; rating: RatingSnapshot }; + }, + result: ChessGameResult, +): GamePlayers => { + const ratingsByColor = { + white: byColor.white.rating, + black: byColor.black.rating, + }; + + const whiteGameResult = getGameResult(result, Color.White, ratingsByColor); + const blackGameResult = getGameResult(result, Color.Black, ratingsByColor); + + return { + white: { + id: byColor.white.id, + name: byColor.white.name, + rating: byColor.white.rating, + ratingDiff: RatingCalculator.compute( + byColor.white.rating, + whiteGameResult, + ).diff, + }, + black: { + id: byColor.black.id, + name: byColor.black.name, + rating: byColor.black.rating, + ratingDiff: RatingCalculator.compute( + byColor.black.rating, + blackGameResult, + ).diff, + }, + }; +}; + +const from = ( + byColor: { + white?: { id: string; name: string; rating?: RatingSnapshot }; + black?: { id: string; name: string; rating?: RatingSnapshot }; + }, + result?: ChessGameResult, +): GamePlayers => { + const { white, black } = byColor; + + // If we have a game result and both players have ratings, calculate rating diffs + if (result && white?.rating && black?.rating) { + const whiteWithRating = { + id: white.id, + name: white.name, + rating: white.rating, + }; + const blackWithRating = { + id: black.id, + name: black.name, + rating: black.rating, + }; + + return fromResult( + { white: whiteWithRating, black: blackWithRating }, + result, + ); + } + + return { + white: white + ? { + id: white.id, + name: white.name, + rating: white.rating, + } + : undefined, + black: black + ? { + id: black.id, + name: black.name, + rating: black.rating, + } + : undefined, + }; +}; + export const GamePlayers = { + from, + fromResult, + getColor: (players: GamePlayers, playerId: string): Maybe => { if (players.white?.id === playerId) { return Color.White; diff --git a/libs/core-game/src/lib/model/GameState.ts b/libs/core-game/src/lib/model/GameState.ts index 55991772..e8736aa4 100644 --- a/libs/core-game/src/lib/model/GameState.ts +++ b/libs/core-game/src/lib/model/GameState.ts @@ -24,7 +24,6 @@ export const GameState = { variant: gameState.variant, isPrivate: gameState.isPrivate, startedAt: gameState.startedAt, - endedAt: gameState.endedAt, createdAt: gameState.createdAt, updatedAt: gameState.updatedAt, }), @@ -35,7 +34,6 @@ export const GameState = { timeControl: TimeControl.noClock(), isPrivate: false, startedAt: undefined, - endedAt: undefined, createdAt: new Date(), updatedAt: new Date(), players: { diff --git a/libs/core-game/src/lib/model/PlayerInfo.ts b/libs/core-game/src/lib/model/PlayerInfo.ts index c5f6e551..b9de7a59 100644 --- a/libs/core-game/src/lib/model/PlayerInfo.ts +++ b/libs/core-game/src/lib/model/PlayerInfo.ts @@ -3,5 +3,6 @@ import { RatingSnapshot } from '@michess/core-rating'; export type PlayerInfo = { id: string; rating?: RatingSnapshot; + ratingDiff?: number; name: string; }; diff --git a/libs/core-game/src/lib/model/TimeControlIn.ts b/libs/core-game/src/lib/model/TimeControlIn.ts new file mode 100644 index 00000000..edea0e38 --- /dev/null +++ b/libs/core-game/src/lib/model/TimeControlIn.ts @@ -0,0 +1,6 @@ +export type TimeControlIn = { + classification: 'bullet' | 'blitz' | 'rapid' | 'correspondence' | 'no_clock'; + initialSec?: number; + incrementSec?: number; + daysPerMove?: number; +}; diff --git a/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts b/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts index ef64d444..d474f729 100644 --- a/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts +++ b/libs/core-game/src/lib/model/__mocks__/GameState.mock.ts @@ -18,7 +18,6 @@ const gameStateMock: GameState = { createdAt: new Date(), updatedAt: new Date(), startedAt: undefined, - endedAt: undefined, variant: 'standard', status: 'IN_PROGRESS', actionRecord: [], diff --git a/libs/core-rating/src/index.ts b/libs/core-rating/src/index.ts index 05943a24..84b8ea09 100644 --- a/libs/core-rating/src/index.ts +++ b/libs/core-rating/src/index.ts @@ -1,2 +1,4 @@ +export * from './lib/model/GameResult'; export * from './lib/model/Rating'; export * from './lib/model/RatingSnapshot'; +export * from './lib/RatingCalculator'; diff --git a/libs/core-rating/src/lib/GlickoTwo.ts b/libs/core-rating/src/lib/GlickoTwo.ts index 61b98d71..59539445 100644 --- a/libs/core-rating/src/lib/GlickoTwo.ts +++ b/libs/core-rating/src/lib/GlickoTwo.ts @@ -1,6 +1,6 @@ import { Maybe } from '@michess/common-utils'; -import { GameResult } from './model/GameResult'; import { Rating } from './model/Rating'; +import { Score } from './model/Score'; const TAO = 0.5; const GLICKO_SCALE_DENOMINATOR = 173.7178; @@ -85,7 +85,7 @@ const updateRatingDeviation = ( const algorithm = ( rating: Maybe, - gameResults: GameResult[], + gameResults: Score[], elapsedPeriodsSinceLastUpdate = 1, ): Rating => { const actualRating = rating || Rating.default(); @@ -117,7 +117,7 @@ const algorithm = ( (sum, gameResult) => { const opponentPlayer = convertToGlickoScale(gameResult.opponent); const EValue = E(mu, opponentPlayer.mu, opponentPlayer.phi); - return sum + g(opponentPlayer.phi) * (gameResult.score - EValue); + return sum + g(opponentPlayer.phi) * (gameResult.value - EValue); }, 0, ); diff --git a/libs/core-rating/src/lib/RatingCalculator.ts b/libs/core-rating/src/lib/RatingCalculator.ts new file mode 100644 index 00000000..354069f0 --- /dev/null +++ b/libs/core-rating/src/lib/RatingCalculator.ts @@ -0,0 +1,35 @@ +import { GlickoTwo } from './GlickoTwo'; +import { GameResult } from './model/GameResult'; +import { RatingSnapshot } from './model/RatingSnapshot'; + +const compute = ( + ratingSnapshot: RatingSnapshot, + result: GameResult, +): { newRating: RatingSnapshot; diff: number } => { + const elapsedPeriodsSinceLastUpdate = + (Date.now() - ratingSnapshot.timestamp.getTime()) / + (1000 * 60 * 60 * 24 * 5); // 1 period = 5 days + const updatedRating = GlickoTwo.algorithm( + { + value: ratingSnapshot.value, + deviation: ratingSnapshot.deviation, + volatility: ratingSnapshot.volatility, + }, + [result], + elapsedPeriodsSinceLastUpdate, + ); + return { + newRating: { + id: 0, + timestamp: result.timestamp, + value: updatedRating.value, + deviation: updatedRating.deviation, + volatility: updatedRating.volatility, + }, + diff: Math.round(updatedRating.value - ratingSnapshot.value), + }; +}; + +export const RatingCalculator = { + compute, +}; diff --git a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts index 1de3b23c..f668a22e 100644 --- a/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts +++ b/libs/core-rating/src/lib/__tests__/GlickoTwo.spec.ts @@ -9,14 +9,14 @@ describe('GlickoTwo', () => { volatility: 0.06, }; const result = GlickoTwo.algorithm(player, [ - { opponent: { value: 1400, deviation: 30, volatility: 0.06 }, score: 1 }, + { opponent: { value: 1400, deviation: 30, volatility: 0.06 }, value: 1 }, { opponent: { value: 1550, deviation: 100, volatility: 0.06 }, - score: 0, + value: 0, }, { opponent: { value: 1700, deviation: 300, volatility: 0.06 }, - score: 0, + value: 0, }, ]); diff --git a/libs/core-rating/src/lib/model/GameResult.ts b/libs/core-rating/src/lib/model/GameResult.ts index 9c393ca2..756f906e 100644 --- a/libs/core-rating/src/lib/model/GameResult.ts +++ b/libs/core-rating/src/lib/model/GameResult.ts @@ -1,6 +1,5 @@ -import { Rating } from './Rating'; +import { Score } from './Score'; -export type GameResult = { - opponent: Rating; - score: number; // 1 = win, 0.5 = draw, 0 = loss +export type GameResult = Score & { + timestamp: Date; }; diff --git a/libs/core-rating/src/lib/model/Score.ts b/libs/core-rating/src/lib/model/Score.ts new file mode 100644 index 00000000..b5380876 --- /dev/null +++ b/libs/core-rating/src/lib/model/Score.ts @@ -0,0 +1,6 @@ +import { Rating } from './Rating'; + +export type Score = { + opponent: Rating; + value: number; // 1 = win, 0.5 = draw, 0 = loss +}; diff --git a/libs/infra-db/src/lib/model/SelectGameWithRelations.ts b/libs/infra-db/src/lib/model/SelectGameWithRelations.ts index 2a28e00d..79d33a5d 100644 --- a/libs/infra-db/src/lib/model/SelectGameWithRelations.ts +++ b/libs/infra-db/src/lib/model/SelectGameWithRelations.ts @@ -2,5 +2,12 @@ import { InferResultType } from './InferResultType'; export type SelectGameWithRelations = InferResultType< 'games', - { moves: true; whitePlayer: true; blackPlayer: true; actions: true } + { + moves: true; + whitePlayer: true; + blackPlayer: true; + actions: true; + whiteRating: true; + blackRating: true; + } >; diff --git a/libs/infra-db/src/lib/repository/GameRepository.ts b/libs/infra-db/src/lib/repository/GameRepository.ts index d826313c..27d219e5 100644 --- a/libs/infra-db/src/lib/repository/GameRepository.ts +++ b/libs/infra-db/src/lib/repository/GameRepository.ts @@ -58,6 +58,8 @@ export class GameRepository extends BaseRepository { with: { whitePlayer: true, blackPlayer: true, + blackRating: true, + whiteRating: true, moves: true, actions: true, }, @@ -85,6 +87,8 @@ export class GameRepository extends BaseRepository { whitePlayer: true, blackPlayer: true, actions: true, + whiteRating: true, + blackRating: true, }, }); } From d2b92f0e4b5cb692abd8b995dcf1346b17f2925d Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sat, 8 Nov 2025 20:27:19 +0100 Subject: [PATCH 12/22] Fix test warnings and migration order --- apps/web-chess/src/test/utils/setup-tests.ts | 9 +++ libs/core-rating/project.json | 11 --- .../migrations/0002_bored_gargoyle.sql | 77 ++++++++++++++----- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/apps/web-chess/src/test/utils/setup-tests.ts b/apps/web-chess/src/test/utils/setup-tests.ts index 4a613b35..567d13d4 100644 --- a/apps/web-chess/src/test/utils/setup-tests.ts +++ b/apps/web-chess/src/test/utils/setup-tests.ts @@ -2,6 +2,15 @@ import '@testing-library/jest-dom/vitest'; import { fetch } from 'cross-fetch'; import { server } from '../mocks/node-chess'; global.fetch = fetch; +// Mock the ResizeObserver +const ResizeObserverMock = vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Stub the global ResizeObserver +vi.stubGlobal('ResizeObserver', ResizeObserverMock); vi.mock('socket.io-client', async () => { return { io: vi.fn(() => ({ diff --git a/libs/core-rating/project.json b/libs/core-rating/project.json index 0ea7cf10..b64c3610 100644 --- a/libs/core-rating/project.json +++ b/libs/core-rating/project.json @@ -5,17 +5,6 @@ "projectType": "library", "tags": [], "targets": { - "build": { - "executor": "@nx/js:tsc", - "outputs": ["{options.outputPath}"], - "options": { - "outputPath": "dist/libs/core-rating", - "tsConfig": "libs/core-rating/tsconfig.lib.json", - "packageJson": "libs/core-rating/package.json", - "main": "libs/core-rating/src/index.ts", - "assets": ["libs/core-rating/*.md"] - } - }, "lint": { "executor": "@nx/eslint:lint" }, diff --git a/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql b/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql index a049cc27..8b854b9e 100644 --- a/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql +++ b/libs/infra-db/src/generated/migrations/0002_bored_gargoyle.sql @@ -1,22 +1,59 @@ -CREATE TABLE "ratings" ( - "id" serial PRIMARY KEY NOT NULL, - "player_id" text NOT NULL, - "game_id" uuid, - "variant" "variant" NOT NULL, - "time_control_classification" time_control_classification NOT NULL, - "rating" real NOT NULL, - "deviation" real NOT NULL, - "volatility" real NOT NULL, - "timestamp" timestamp DEFAULT now() NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL +CREATE TABLE + "ratings" ( + "id" serial PRIMARY KEY NOT NULL, + "player_id" text NOT NULL, + "game_id" uuid, + "variant" "variant" NOT NULL, + "time_control_classification" time_control_classification NOT NULL, + "rating" real NOT NULL, + "deviation" real NOT NULL, + "volatility" real NOT NULL, + "timestamp" timestamp DEFAULT now () NOT NULL, + "created_at" timestamp DEFAULT now () NOT NULL + ); + +--> statement-breakpoint +ALTER TABLE "games" +ADD COLUMN "white_rating_id" integer; + +--> statement-breakpoint +ALTER TABLE "games" +ADD COLUMN "black_rating_id" integer; + +--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "game_variant_tc_unique" UNIQUE ( + "game_id", + "variant", + "time_control_classification" ); + +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_player_id_users_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."users" ("id") ON DELETE cascade ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_id_games_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games" ("game_id") ON DELETE set null ON UPDATE no action; + +--> statement-breakpoint +ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_variant_tc_fk" FOREIGN KEY ( + "game_id", + "variant", + "time_control_classification" +) REFERENCES "public"."games" ( + "game_id", + "variant", + "time_control_classification" +) ON DELETE no action ON UPDATE no action; + +--> statement-breakpoint +CREATE INDEX "idx_ratings_player_variant_tc_created" ON "ratings" USING btree ( + "player_id", + "variant", + "time_control_classification", + "timestamp" DESC NULLS LAST +); + +--> statement-breakpoint +ALTER TABLE "games" ADD CONSTRAINT "games_white_rating_id_ratings_id_fk" FOREIGN KEY ("white_rating_id") REFERENCES "public"."ratings" ("id") ON DELETE set null ON UPDATE no action; + --> statement-breakpoint -ALTER TABLE "games" ADD COLUMN "white_rating_id" integer;--> statement-breakpoint -ALTER TABLE "games" ADD COLUMN "black_rating_id" integer;--> statement-breakpoint -ALTER TABLE "ratings" ADD CONSTRAINT "ratings_player_id_users_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_id_games_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("game_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "ratings" ADD CONSTRAINT "ratings_game_variant_tc_fk" FOREIGN KEY ("game_id","variant","time_control_classification") REFERENCES "public"."games"("game_id","variant","time_control_classification") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_ratings_player_variant_tc_created" ON "ratings" USING btree ("player_id","variant","time_control_classification","timestamp" DESC NULLS LAST);--> statement-breakpoint -ALTER TABLE "games" ADD CONSTRAINT "games_white_rating_id_ratings_id_fk" FOREIGN KEY ("white_rating_id") REFERENCES "public"."ratings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "games" ADD CONSTRAINT "games_black_rating_id_ratings_id_fk" FOREIGN KEY ("black_rating_id") REFERENCES "public"."ratings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "games" ADD CONSTRAINT "game_variant_tc_unique" UNIQUE("game_id","variant","time_control_classification"); \ No newline at end of file +ALTER TABLE "games" ADD CONSTRAINT "games_black_rating_id_ratings_id_fk" FOREIGN KEY ("black_rating_id") REFERENCES "public"."ratings" ("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file From b361996e309166ed570a9b45408b0b1f9e3c7a63 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sat, 8 Nov 2025 21:54:08 +0100 Subject: [PATCH 13/22] Fix broke test --- .../src/app/features/lobby/__tests__/GameLobby.spec.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx b/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx index c2a487df..2edc242d 100644 --- a/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx +++ b/apps/web-chess/src/app/features/lobby/__tests__/GameLobby.spec.tsx @@ -4,6 +4,7 @@ import { http, HttpResponse, server } from '../../../../test/mocks/node-chess'; import { render, socketClient, + within, } from '../../../../test/utils/custom-testing-library'; import { GameLobby } from '../GameLobby'; @@ -15,7 +16,7 @@ describe('GameLobby', () => { expect(getByText('+ Create Game')).toBeTruthy(); }); - it('should call onCreateGame when create button is clicked', async () => { + it('should call onCreateGame when a game is created', async () => { const user = userEvent.setup(); const onCreateGame = vi.fn(); const gameDetailsMockV1: GameDetailsV1 = { @@ -43,6 +44,11 @@ describe('GameLobby', () => { await user.click(createButton); + const dialog = await findByRole('dialog'); + const createGameButton = within(dialog).getByRole('button', { + name: 'Create Game', + }); + await user.click(createGameButton); expect(onCreateGame).toHaveBeenCalledTimes(1); }); From dc517db53f7ca084ebe9a5be808cdde464fefecc Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sun, 9 Nov 2025 12:51:44 +0100 Subject: [PATCH 14/22] Add rating decay job --- .../src/lib/user/service/RatingsService.ts | 228 +++++++++++++++++- libs/core-game/src/lib/ChessGame.ts | 30 +++ .../src/lib/model/ChessGameErrorCode.ts | 1 + .../src/lib/model/ChessGameResultType.ts | 16 ++ libs/core-rating/src/lib/RatingCalculator.ts | 26 ++ .../src/lib/repository/GameRepository.ts | 58 ++++- .../src/lib/repository/RatingRepository.ts | 39 ++- 7 files changed, 383 insertions(+), 15 deletions(-) diff --git a/libs/api-service/src/lib/user/service/RatingsService.ts b/libs/api-service/src/lib/user/service/RatingsService.ts index b568e4df..dad8f1b5 100644 --- a/libs/api-service/src/lib/user/service/RatingsService.ts +++ b/libs/api-service/src/lib/user/service/RatingsService.ts @@ -1,16 +1,232 @@ -import { GameVariantType, TimeControlClassification } from '@michess/core-game'; -import { Rating } from '@michess/core-rating'; -import { RatingRepository } from '@michess/infra-db'; +import { logger } from '@michess/be-utils'; +import { + GameState, + GameVariantType, + TimeControlClassification, +} from '@michess/core-game'; +import { Rating, RatingCalculator } from '@michess/core-rating'; +import { + CacheRepository, + GameRepository, + RatingRepository, +} from '@michess/infra-db'; +import { Job, Queue, Worker } from 'bullmq'; +import { GameMapper } from '../../games/mapper/GameMapper'; + +type UpdateRatingJobData = { + playerId: string; + variant: GameVariantType; + timeControlClassification: TimeControlClassification; +}; + +const STALE_RATING_DAYS = 5; export class RatingsService { - constructor(private readonly repositories: RatingRepository) {} + private ratingDecayQueue: Queue; + private ratingDecayWorker: Worker; + private updateRatingQueue: Queue; + private updateRatingWorker: Worker; + + constructor( + private readonly ratingRepository: RatingRepository, + private readonly gameRepository: GameRepository, + private readonly cacheRepository: CacheRepository, + ) { + const connectionOptions = { connection: this.cacheRepository.client }; + + this.ratingDecayQueue = new Queue('rating-decay', connectionOptions); + this.ratingDecayWorker = new Worker( + 'rating-decay', + this.processStaleRatings.bind(this), + connectionOptions, + ); + + this.updateRatingQueue = new Queue('update-rating', connectionOptions); + this.updateRatingWorker = new Worker( + 'update-rating', + this.updatePlayerRating.bind(this), + connectionOptions, + ); + } + + async initialize() { + // Schedule job to check for stale ratings every day at 2 AM + await this.ratingDecayQueue.upsertJobScheduler('check-stale-ratings', { + pattern: '0 2 * * *', + }); + } + + async close() { + logger.info('Closing ratings service'); + await this.ratingDecayWorker.close(); + await this.ratingDecayQueue.close(); + await this.updateRatingWorker.close(); + await this.updateRatingQueue.close(); + } + + async queueRatingUpdate( + playerId: string, + variant: GameVariantType, + timeControlClassification: TimeControlClassification, + ): Promise { + const deduplicationId = `${playerId}-${variant}-${timeControlClassification}`; + + await this.updateRatingQueue.add( + 'update-rating', + { + playerId, + variant, + timeControlClassification, + }, + { + deduplication: { + id: deduplicationId, + }, + }, + ); + } + + async queueRatingUpdateForGame(gameState: GameState): Promise { + const whitePlayerId = gameState.players.white?.id; + const blackPlayerId = gameState.players.black?.id; + + if (whitePlayerId) { + await this.queueRatingUpdate( + whitePlayerId, + gameState.variant, + gameState.timeControl.classification, + ); + } + + if (blackPlayerId) { + await this.queueRatingUpdate( + blackPlayerId, + gameState.variant, + gameState.timeControl.classification, + ); + } + } + + private async processStaleRatings(): Promise { + logger.info('Processing stale ratings...'); + const cutoffDate = new Date( + Date.now() - STALE_RATING_DAYS * 24 * 60 * 60 * 1000, + ); + + const staleRatings = + await this.ratingRepository.getStaleRatings(cutoffDate); + + logger.info( + { count: staleRatings.length, cutoffDate }, + 'Found stale ratings', + ); + + for (const staleRating of staleRatings) { + await this.queueRatingUpdate( + staleRating.playerId, + staleRating.variant, + staleRating.timeControlClassification, + ); + } + } + + private async updatePlayerRating( + job: Job, + ): Promise { + const { playerId, variant, timeControlClassification } = job.data; + + logger.info( + { + playerId, + variant, + timeControlClassification, + }, + 'Updating player rating', + ); + + // Get the current rating to determine when it was last updated + const currentRating = await this.ratingRepository.getRatingByPlayerId( + playerId, + variant, + timeControlClassification, + ); + + if (!currentRating) { + logger.warn( + { playerId, variant, timeControlClassification }, + 'No rating found for player, skipping update', + ); + return; + } + + // Capture the "until" timestamp at the start to define our processing window + const processingEndTime = new Date(); + + // Query for games in the window [lastRatingTimestamp, processingEndTime] + const { games } = await this.gameRepository.queryGames( + { + playerId, + variant, + timeControlClassification, + completedAfter: currentRating.timestamp, + completedBefore: processingEndTime, + status: ['ENDED'], + }, + { sortBy: 'endedAt', sortOrder: 'asc' }, + ); + + logger.info( + { + playerId, + variant, + timeControlClassification, + gamesCount: games.length, + lastRatingTimestamp: currentRating.timestamp, + processingEndTime, + }, + 'Found games since last rating update', + ); + + let latestRating = currentRating; + for (const game of games) { + const chessGame = GameMapper.fromSelectGameWithRelations(game); + const gameResult = chessGame.getPlayerGameResult(playerId); + if (gameResult) { + const { newRating } = RatingCalculator.compute( + latestRating, + gameResult, + ); + const gameRating = await this.ratingRepository.createRating({ + playerId, + variant, + timeControlClassification, + rating: newRating.value, + deviation: newRating.deviation, + volatility: newRating.volatility, + timestamp: gameResult.timestamp, + }); + latestRating = gameRating; + } + } + + const newRating = RatingCalculator.decay(latestRating, processingEndTime); + await this.ratingRepository.createRating({ + playerId, + variant, + timeControlClassification, + rating: newRating.value, + deviation: newRating.deviation, + volatility: newRating.volatility, + timestamp: processingEndTime, + }); + } getRatingByPlayerId( playerId: string, variant: GameVariantType, timeControl: TimeControlClassification, ) { - const rating = this.repositories.getRatingByPlayerId( + const rating = this.ratingRepository.getRatingByPlayerId( playerId, variant, timeControl, @@ -19,7 +235,7 @@ export class RatingsService { return rating; } else { const defaultRating = Rating.default(); - return this.repositories.createRating({ + return this.ratingRepository.createRating({ playerId, variant, timeControlClassification: timeControl, diff --git a/libs/core-game/src/lib/ChessGame.ts b/libs/core-game/src/lib/ChessGame.ts index 36a69130..c182ffd2 100644 --- a/libs/core-game/src/lib/ChessGame.ts +++ b/libs/core-game/src/lib/ChessGame.ts @@ -6,6 +6,7 @@ import { FenStr, Move, } from '@michess/core-board'; +import { GameResult } from '@michess/core-rating'; import { ChessGameActions } from './actions/ChessGameActions'; import { GameActionIn } from './actions/model/GameActionIn'; import { GameActionOption } from './actions/model/GameActionOption'; @@ -14,6 +15,7 @@ import { ChessClock } from './ChessClock'; import { ChessGameError } from './model/ChessGameError'; import { ChessGameIn } from './model/ChessGameIn'; import { ChessGameResult } from './model/ChessGameResult'; +import { ChessGameResultType } from './model/ChessGameResultType'; import { GameMeta } from './model/GameMeta'; import { GamePlayers } from './model/GamePlayers'; import { GameState } from './model/GameState'; @@ -44,6 +46,7 @@ export type ChessGame = { isPlayerInGame(playerId: string): boolean; hasNewStatus(oldChess: ChessGame): boolean; hasNewActionOptions(oldChess: ChessGame): boolean; + getPlayerGameResult(playerId: string): Maybe; clock: Maybe; id: string; }; @@ -314,6 +317,33 @@ const fromGameStateInternal = ( isPlayerInGame: (playerId: string): boolean => { return getPlayerEntry(playerId) !== undefined; }, + getPlayerGameResult: (playerId: string): Maybe => { + const playerEntry = getPlayerEntry(playerId); + if (!playerEntry) { + throw new ChessGameError( + 'not_in_game', + 'Player is not part of the game', + ); + } + const [side] = playerEntry; + if (!gameStateInternal.result) { + throw new ChessGameError('game_not_over', 'Game has not ended yet'); + } + const opponentRating = + gameStateInternal.players[Color.opposite(side)]?.rating; + if (opponentRating) { + return { + opponent: opponentRating, + timestamp: new Date(gameStateInternal.result.timestamp), + value: ChessGameResultType.toScore( + gameStateInternal.result.type, + side, + ), + }; + } else { + return undefined; + } + }, hasNewActionOptions: (oldChess: ChessGame): boolean => { return !gameStateInternal.additionalActions.hasExactOptions( oldChess.getAdditionalActions(), diff --git a/libs/core-game/src/lib/model/ChessGameErrorCode.ts b/libs/core-game/src/lib/model/ChessGameErrorCode.ts index fe93df5a..14a82fa6 100644 --- a/libs/core-game/src/lib/model/ChessGameErrorCode.ts +++ b/libs/core-game/src/lib/model/ChessGameErrorCode.ts @@ -2,5 +2,6 @@ export type ChessGameErrorCode = | 'game_is_over' | 'not_your_turn' | 'player_flagged' + | 'game_not_over' | 'not_in_game' | 'action_not_available'; diff --git a/libs/core-game/src/lib/model/ChessGameResultType.ts b/libs/core-game/src/lib/model/ChessGameResultType.ts index 4f0c9eed..1736fd49 100644 --- a/libs/core-game/src/lib/model/ChessGameResultType.ts +++ b/libs/core-game/src/lib/model/ChessGameResultType.ts @@ -1 +1,17 @@ +import { Color } from '@michess/core-board'; + export type ChessGameResultType = 'white_win' | 'black_win' | 'draw'; + +const toScore = (resultType: ChessGameResultType, color: Color): number => { + switch (resultType) { + case 'white_win': + return color === 'white' ? 1 : 0; + case 'black_win': + return color === 'black' ? 1 : 0; + case 'draw': + return 0.5; + } +}; +export const ChessGameResultType = { + toScore, +}; diff --git a/libs/core-rating/src/lib/RatingCalculator.ts b/libs/core-rating/src/lib/RatingCalculator.ts index 354069f0..3ded8f05 100644 --- a/libs/core-rating/src/lib/RatingCalculator.ts +++ b/libs/core-rating/src/lib/RatingCalculator.ts @@ -30,6 +30,32 @@ const compute = ( }; }; +const decay = ( + ratingSnapshot: RatingSnapshot, + currentDate: Date, +): RatingSnapshot => { + const elapsedPeriodsSinceLastUpdate = + (Date.now() - ratingSnapshot.timestamp.getTime()) / + (1000 * 60 * 60 * 24 * 5); // 1 period = 5 days + const updatedRating = GlickoTwo.algorithm( + { + value: ratingSnapshot.value, + deviation: ratingSnapshot.deviation, + volatility: ratingSnapshot.volatility, + }, + [], + elapsedPeriodsSinceLastUpdate, + ); + return { + id: ratingSnapshot.id, + timestamp: currentDate, + value: updatedRating.value, + deviation: updatedRating.deviation, + volatility: updatedRating.volatility, + }; +}; + export const RatingCalculator = { compute, + decay, }; diff --git a/libs/infra-db/src/lib/repository/GameRepository.ts b/libs/infra-db/src/lib/repository/GameRepository.ts index 27d219e5..c8defddc 100644 --- a/libs/infra-db/src/lib/repository/GameRepository.ts +++ b/libs/infra-db/src/lib/repository/GameRepository.ts @@ -1,6 +1,10 @@ import { Maybe } from '@michess/common-utils'; -import { GameStatusType } from '@michess/core-game'; -import { and, count, eq, inArray, lt, sql } from 'drizzle-orm'; +import { + GameStatusType, + GameVariantType, + TimeControlClassification, +} from '@michess/core-game'; +import { and, asc, count, eq, gt, inArray, lt, or, sql } from 'drizzle-orm'; import { GameStatusEnum } from '../model/GameStatusEnum'; import { InsertGame } from '../model/InsertGame'; import { SelectGame } from '../model/SelectGame'; @@ -9,14 +13,22 @@ import { games } from '../schema'; import { BaseRepository } from './BaseRepository'; type QueryOptions = { - page: { + page?: { page: number; pageSize: number; }; status?: GameStatusType[]; playerId?: string; + variant?: GameVariantType; + timeControlClassification?: TimeControlClassification; + completedAfter?: Date; + completedBefore?: Date; private?: boolean; }; +type QuerySortOptions = { + sortBy: 'createdAt' | 'endedAt'; + sortOrder: 'asc' | 'desc'; +}; type DeleteGamesOptions = { olderThan: Date; @@ -33,8 +45,10 @@ export class GameRepository extends BaseRepository { where: eq(this.schema.games.gameId, id), }); } - - async queryGames(options: QueryOptions): Promise { + async queryGames( + options: QueryOptions, + sortOptions?: QuerySortOptions, + ): Promise { // Build the where condition for reuse const andConditions = []; if (options.status) { @@ -48,13 +62,39 @@ export class GameRepository extends BaseRepository { if (typeof options.private === 'boolean') { andConditions.push(eq(games.isPrivate, options.private)); } + if (options.playerId) { + andConditions.push( + or( + eq(games.whitePlayerId, options.playerId), + eq(games.blackPlayerId, options.playerId), + ), + ); + } + if (options.variant) { + andConditions.push(eq(games.variant, options.variant)); + } + if (options.timeControlClassification) { + andConditions.push( + eq(games.timeControlClassification, options.timeControlClassification), + ); + } + if (options.completedAfter) { + andConditions.push(gt(games.endedAt, options.completedAfter)); + } + if (options.completedBefore) { + andConditions.push(lt(games.endedAt, options.completedBefore)); + } const statusFilter = andConditions.length > 0 ? and(...andConditions) : undefined; const [gamesResult, countResult] = await Promise.all([ // Get paginated games with relations this.db.query.games.findMany({ - orderBy: (games, { desc }) => [desc(games.createdAt)], + orderBy: (games, { desc }) => [ + sortOptions?.sortOrder === 'asc' + ? asc(games[sortOptions?.sortBy || 'createdAt']) + : desc(games[sortOptions?.sortBy || 'createdAt']), + ], with: { whitePlayer: true, blackPlayer: true, @@ -63,8 +103,10 @@ export class GameRepository extends BaseRepository { moves: true, actions: true, }, - offset: (options.page.page - 1) * options.page.pageSize, - limit: options.page.pageSize, + offset: options.page + ? (options.page.page - 1) * options.page.pageSize + : undefined, + limit: options.page?.pageSize, where: statusFilter, }), // Get total count with same filter diff --git a/libs/infra-db/src/lib/repository/RatingRepository.ts b/libs/infra-db/src/lib/repository/RatingRepository.ts index 6b1f69c5..235a9099 100644 --- a/libs/infra-db/src/lib/repository/RatingRepository.ts +++ b/libs/infra-db/src/lib/repository/RatingRepository.ts @@ -4,12 +4,19 @@ import { GameVariantType, TimeControlClassification, } from '@michess/core-game'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, lt, sql } from 'drizzle-orm'; import { InsertRating } from '../model/InsertRating'; import { SelectRating } from '../model/SelectRating'; import { ratings } from '../schema'; import { BaseRepository } from './BaseRepository'; +export type StaleRating = { + playerId: string; + variant: GameVariantType; + timeControlClassification: TimeControlClassification; + lastRatingTimestamp: Date; +}; + export class RatingRepository extends BaseRepository { toGameRating(rating: SelectRating): GameRating { return { @@ -49,4 +56,34 @@ export class RatingRepository extends BaseRepository { return undefined; } } + + async getStaleRatings(cutoffDate: Date): Promise { + // Get the most recent rating for each player/variant/timeControl combination + // where the timestamp is older than the cutoff date + const result = await this.db + .select({ + playerId: ratings.playerId, + variant: ratings.variant, + timeControlClassification: ratings.timeControlClassification, + lastRatingTimestamp: sql`MAX(${ratings.timestamp})`.as( + 'last_rating_timestamp', + ), + }) + .from(ratings) + .where(lt(ratings.timestamp, cutoffDate)) + .groupBy( + ratings.playerId, + ratings.variant, + ratings.timeControlClassification, + ) + .having(sql`MAX(${ratings.timestamp}) < ${cutoffDate}`); + + return result.map((row) => ({ + playerId: row.playerId, + variant: row.variant as GameVariantType, + timeControlClassification: + row.timeControlClassification as TimeControlClassification, + lastRatingTimestamp: row.lastRatingTimestamp, + })); + } } From 44aa8f7e1cf9f6058a98c69472ff98cc758011ec Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sun, 9 Nov 2025 13:40:00 +0100 Subject: [PATCH 15/22] Handle race condition for rating updates --- libs/api-service/src/lib/lock/model/ResourceType.ts | 2 +- .../src/lib/user/service/RatingsService.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/libs/api-service/src/lib/lock/model/ResourceType.ts b/libs/api-service/src/lib/lock/model/ResourceType.ts index 606f77ee..da9f1d06 100644 --- a/libs/api-service/src/lib/lock/model/ResourceType.ts +++ b/libs/api-service/src/lib/lock/model/ResourceType.ts @@ -1 +1 @@ -export type ResourceType = 'game' | 'user'; +export type ResourceType = 'game' | 'user' | 'rating'; diff --git a/libs/api-service/src/lib/user/service/RatingsService.ts b/libs/api-service/src/lib/user/service/RatingsService.ts index dad8f1b5..bb28839f 100644 --- a/libs/api-service/src/lib/user/service/RatingsService.ts +++ b/libs/api-service/src/lib/user/service/RatingsService.ts @@ -12,6 +12,7 @@ import { } from '@michess/infra-db'; import { Job, Queue, Worker } from 'bullmq'; import { GameMapper } from '../../games/mapper/GameMapper'; +import { LockService } from '../../lock/service/LockService'; type UpdateRatingJobData = { playerId: string; @@ -31,6 +32,7 @@ export class RatingsService { private readonly ratingRepository: RatingRepository, private readonly gameRepository: GameRepository, private readonly cacheRepository: CacheRepository, + private readonly lockService: LockService, ) { const connectionOptions = { connection: this.cacheRepository.client }; @@ -159,6 +161,15 @@ export class RatingsService { return; } + // Remove deduplication key and acquire lock + // This allows new jobs to be queued while we process, but they'll wait for the lock + const deduplicationId = `${playerId}-${variant}-${timeControlClassification}`; + await using _ = await this.lockService.acquireLock( + 'rating', + deduplicationId, + ); + await this.updateRatingQueue.removeDeduplicationKey(deduplicationId); + // Capture the "until" timestamp at the start to define our processing window const processingEndTime = new Date(); From 8076da8965a6ae4ed1ae2759125c51018dc118a9 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sun, 9 Nov 2025 13:44:28 +0100 Subject: [PATCH 16/22] Stop using GameDetailsMapper --- .../src/lib/games/service/GamesService.ts | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/libs/api-service/src/lib/games/service/GamesService.ts b/libs/api-service/src/lib/games/service/GamesService.ts index 50ed8d29..db071d90 100644 --- a/libs/api-service/src/lib/games/service/GamesService.ts +++ b/libs/api-service/src/lib/games/service/GamesService.ts @@ -32,7 +32,6 @@ import { Session } from '../../auth/model/Session'; import { LockService } from '../../lock/service/LockService'; import { PageResponseMapper } from '../../mapper/PageResponseMapper'; import { RatingsService } from '../../user/service/RatingsService'; -import { GameDetailsMapper } from '../mapper/GameDetailsMapper'; import { GameMapper } from '../mapper/GameMapper'; type TimeControlJobData = { @@ -204,14 +203,10 @@ export class GamesService { status: ['WAITING'], private: false, }); - const gameDetails = games.map( - GameDetailsMapper.fromSelectGameWithRelations, - ); + const chessGames = games.map(GameMapper.fromSelectGameWithRelations); return PageResponseMapper.toPageResponse({ - data: gameDetails.map((game) => - GameDetailsMapper.toLobbyGameItemV1(game), - ), + data: chessGames.map((game) => GameMapper.toLobbyGameItemV1(game)), limit, totalItems: totalCount, page, @@ -231,13 +226,11 @@ export class GamesService { playerId: userId, status: query.status ? [query.status] : ['ENDED', 'IN_PROGRESS'], }); - const gameDetails = games.map( - GameDetailsMapper.fromSelectGameWithRelations, - ); + const gameDetails = games.map(GameMapper.fromSelectGameWithRelations); return PageResponseMapper.toPageResponse({ data: gameDetails.map((game) => - GameDetailsMapper.toPlayerGameInfoV1(game, userId), + GameMapper.toPlayerGameInfoV1(game, userId), ), limit, totalItems: totalCount, @@ -279,10 +272,9 @@ export class GamesService { }, data.side, ); - const updatedGameState = updatedGame.getState(); await this.gameRepository.updateGame( gameState.id, - GameDetailsMapper.toInsertGame(updatedGameState), + GameMapper.toInsertGame(updatedGame), ); return GameMapper.toGameDetailsV1(updatedGame); } @@ -298,11 +290,10 @@ export class GamesService { } const updatedGame = chessGame.leaveGame(session.userId); - const updatedGameState = updatedGame.getState(); await this.gameRepository.updateGame( - chessGame.getState().id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); return GameMapper.toGameDetailsV1(updatedGame); } @@ -325,7 +316,7 @@ export class GamesService { assertDefined(newMove, 'No move found after playing move'); await this.moveRepository.createMove({ - gameId: updatedGameState.id, + gameId: updatedGame.id, uci: data.uci, movedAt: new Date(newMove.timestamp), }); @@ -335,8 +326,8 @@ export class GamesService { chessGame.hasNewActionOptions(updatedGame); if (gameStateUpdated) { await this.gameRepository.updateGame( - updatedGameState.id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); } return { updatedGame, move: newMove, gameStateUpdated }; @@ -377,8 +368,8 @@ export class GamesService { const updatedGame = chessGame.makeAction(session.userId, gameActionIn); const updatedGameState = updatedGame.getState(); await this.gameRepository.updateGame( - updatedGameState.id, - GameDetailsMapper.toInsertGame(updatedGameState), + updatedGame.id, + GameMapper.toInsertGame(updatedGame), ); const action = updatedGameState.actionRecord.at(-1); action && From 7e525968686923e84d16fe5ab9f3fa6fbde0c144 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Sun, 9 Nov 2025 13:45:00 +0100 Subject: [PATCH 17/22] Remove GameDetailsMapper --- .../src/lib/games/mapper/GameDetailsMapper.ts | 316 ------------------ 1 file changed, 316 deletions(-) delete mode 100644 libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts diff --git a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts b/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts deleted file mode 100644 index 2fb14c6e..00000000 --- a/libs/api-service/src/lib/games/mapper/GameDetailsMapper.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { - GameDetailsV1, - GameVariantV1, - LobbyGameItemV1, - PlayerGameInfoV1, - PlayerInfoV1, -} from '@michess/api-schema'; -import { isDefined, Maybe } from '@michess/common-utils'; -import { ChessPosition, Color, Move } from '@michess/core-board'; -import { - ChessGameResult, - ChessGameResultType, - ClockInstant, - DrawReasonType, - GameAction, - GameActionOption, - GameDetails, - GameMeta, - GamePlayers, - GameStatusType, - PlayerInfo, - TimeControl, -} from '@michess/core-game'; -import { - InsertGame, - SelectGame, - SelectGameWithRelations, -} from '@michess/infra-db'; - -const TO_RESULT_TYPE_MAPPING: Record< - SelectGameWithRelations['result'], - ChessGameResultType -> = { - '1-0': 'white_win', - '0-1': 'black_win', - '1/2-1/2': 'draw', - '0-0': 'draw', -}; - -const FROM_RESULT_TYPE_MAPPING: Record< - ChessGameResultType, - SelectGameWithRelations['result'] -> = { - white_win: '1-0', - black_win: '0-1', - draw: '1/2-1/2', -}; - -const FROM_STATUS_TYPE_MAPPING: Record< - SelectGameWithRelations['status'], - GameStatusType -> = { - empty: 'EMPTY', - ready: 'READY', - waiting: 'WAITING', - 'in-progress': 'IN_PROGRESS', - end: 'ENDED', -}; - -const TO_STATUS_TYPE_MAPPING: Record = { - EMPTY: 'empty', - READY: 'ready', - WAITING: 'waiting', - IN_PROGRESS: 'in-progress', - ENDED: 'end', -}; - -const toGameMeta = (game: SelectGameWithRelations | SelectGame): GameMeta => ({ - id: game.gameId, - variant: game.variant ?? 'standard', - isPrivate: game.isPrivate, - createdAt: game.createdAt, - startedAt: game.startedAt ?? undefined, - updatedAt: game.updatedAt, -}); - -const toPlayerInfo = (player: { - id: string; - name: string | null; -}): PlayerInfo => ({ - id: player.id, - name: player.name ?? 'Anonymous', -}); - -const toGamePlayers = (game: SelectGameWithRelations): GamePlayers => ({ - white: game.whitePlayer ? toPlayerInfo(game.whitePlayer) : undefined, - black: game.blackPlayer ? toPlayerInfo(game.blackPlayer) : undefined, -}); - -const toGameActions = (game: SelectGameWithRelations): GameAction[] => { - return game.actions - .map((action) => { - const base = { - color: action.color, - moveNumber: action.moveNumber, - }; - switch (action.type) { - case 'resign': - case 'offer_draw': { - return { ...base, type: action.type }; - } - case 'accept_draw': - return { - ...base, - type: action.type, - reason: - (action.payload?.reason as DrawReasonType) ?? 'by_agreement', - }; - } - }) - .filter(isDefined); -}; - -const toTimeControl = ({ - timeControl, - timeControlClassification, -}: SelectGame): TimeControl => { - switch (timeControlClassification) { - case 'bullet': - case 'blitz': - case 'rapid': - return { - classification: timeControlClassification, - incrementSec: - timeControl && 'increment' in timeControl ? timeControl.increment : 0, - initialSec: - timeControl && 'initial' in timeControl ? timeControl.initial : 0, - }; - case 'correspondence': - return { - classification: timeControlClassification, - daysPerMove: - timeControl && 'daysPerMove' in timeControl - ? timeControl.daysPerMove - : 0, - }; - case 'no_clock': - default: - return { - classification: 'no_clock', - }; - } -}; - -const toChessGameResult = ({ - result, - endedAt, -}: SelectGame | SelectGameWithRelations): Maybe => { - return result !== '0-0' - ? { - timestamp: endedAt ? endedAt.getTime() : 0, - type: TO_RESULT_TYPE_MAPPING[result], - } - : undefined; -}; - -export const GameDetailsMapper = { - fromSelectGame(game: SelectGame): GameDetails { - return { - players: { - white: undefined, - black: undefined, - }, - timeControl: toTimeControl(game), - actionRecord: [], - result: toChessGameResult(game), - status: FROM_STATUS_TYPE_MAPPING[game.status], - resultStr: game.result, - initialPosition: ChessPosition.standardInitial(), - movesRecord: [], - ...toGameMeta(game), - }; - }, - - fromSelectGameWithRelations(game: SelectGameWithRelations): GameDetails { - const players = toGamePlayers(game); - return { - ...toGameMeta(game), - timeControl: toTimeControl(game), - actionRecord: toGameActions(game), - players, - status: FROM_STATUS_TYPE_MAPPING[game.status], - variant: game.variant ?? 'standard', - isPrivate: game.isPrivate, - initialPosition: ChessPosition.standardInitial(), - result: toChessGameResult(game), - resultStr: game.result, - movesRecord: game.moves.map((move) => ({ - ...Move.fromUci(move.uci), - timestamp: move.movedAt.getTime(), - })), - }; - }, - - toPlayerInfoV1(player: PlayerInfo): PlayerInfoV1 { - return { - id: player.id, - name: player.name, - rating: player.rating?.value, - ratingDiff: player.ratingDiff, - }; - }, - - toLobbyGameItemV1(game: GameDetails): LobbyGameItemV1 { - return { - id: game.id, - opponent: game.players.white - ? this.toPlayerInfoV1(game.players.white) - : game.players.black - ? this.toPlayerInfoV1(game.players.black) - : { - id: 'anon', - name: 'Anonymous', - }, - variant: game.variant as GameVariantV1, - createdAt: game.createdAt.toISOString(), - availableColor: !game.players.white - ? 'white' - : !game.players.black - ? 'black' - : 'spectator', - }; - }, - toPlayerGameInfoV1(game: GameDetails, playerId: string): PlayerGameInfoV1 { - const ownSide = - game.players.white?.id === playerId - ? 'white' - : game.players.black?.id === playerId - ? 'black' - : 'white'; // Should not happen, but fail gracefully - const opponent: PlayerInfo = - ownSide === 'white' - ? (game.players.black ?? { id: 'anon', name: 'Anonymous' }) - : (game.players.white ?? { id: 'anon', name: 'Anonymous' }); - const initialTurn = game.initialPosition.turn; - return { - id: game.id, - opponent: { - id: opponent.id, - name: opponent.name, - }, - ownSide, - turn: - game.movesRecord.length % 2 === 0 - ? initialTurn - : Color.opposite(initialTurn), - variant: game.variant as GameVariantV1, - result: game.result - ? { - type: game.result.type, - } - : undefined, - }; - }, - toGameDetailsV1({ - game, - clock, - availableActions, - }: { - game: GameDetails; - clock: Maybe; - availableActions?: GameActionOption[]; - }): GameDetailsV1 { - return { - id: game.id, - status: game.status, - clock, - timeControl: game.timeControl, - players: { - white: game.players.white - ? { - id: game.players.white.id, - rating: game.players.white.rating?.value, - ratingDiff: game.players.white.ratingDiff, - name: game.players.white.name, - } - : undefined, - black: game.players.black - ? { - id: game.players.black.id, - rating: game.players.black.rating?.value, - ratingDiff: game.players.black.ratingDiff, - name: game.players.black.name, - } - : undefined, - }, - result: game.result - ? { - type: game.result.type, - } - : undefined, - variant: 'standard', - isPrivate: game.isPrivate, - actionOptions: availableActions ?? [], - moves: game.movesRecord.map((move) => ({ - uci: Move.toUci(move), - })), - initialPosition: undefined, - startedAt: game.startedAt ?? undefined, - }; - }, - - toInsertGame(game: GameDetails): InsertGame { - return { - isPrivate: game.isPrivate, - whitePlayerId: game.players.white ? game.players.white.id : null, - blackPlayerId: game.players.black ? game.players.black.id : null, - status: TO_STATUS_TYPE_MAPPING[game.status], - startedAt: game.startedAt ?? null, - endedAt: game.result?.timestamp ? new Date(game.result.timestamp) : null, - blackRatingId: game.players.black?.rating?.id ?? null, - whiteRatingId: game.players.white?.rating?.id ?? null, - result: game.result ? FROM_RESULT_TYPE_MAPPING[game.result.type] : '0-0', - }; - }, -}; From 2a12c9e889a1a859aa48e99e90402a060489ac77 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 10 Nov 2025 11:40:35 +0100 Subject: [PATCH 18/22] Queue rating updates when a game is ended --- .../src/lib/games/service/GamesService.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/libs/api-service/src/lib/games/service/GamesService.ts b/libs/api-service/src/lib/games/service/GamesService.ts index db071d90..d7194b80 100644 --- a/libs/api-service/src/lib/games/service/GamesService.ts +++ b/libs/api-service/src/lib/games/service/GamesService.ts @@ -152,6 +152,18 @@ export class GamesService { } } + private async handleGameEnd( + chessGame: ChessGame, + previousGame?: ChessGame, + ): Promise { + const state = chessGame.getState(); + // If previous game was not provided assume that it was IN_PROGRESS. + const previousStatus = previousGame?.getState().status ?? 'IN_PROGRESS'; + if (previousStatus !== 'ENDED' && state.status === 'ENDED') { + await this.ratingsService.queueRatingUpdateForGame(state); + } + } + async createGame(data: CreateGameV1): Promise { const { timeControlClassification, @@ -345,6 +357,7 @@ export class GamesService { }; if (gameStateUpdated) { + await this.handleGameEnd(updatedGame); return { gameDetails: GameMapper.toGameDetailsV1(updatedGame), move: moveMadeV1, @@ -374,6 +387,9 @@ export class GamesService { const action = updatedGameState.actionRecord.at(-1); action && (await this.actionRepository.createAction(updatedGameState.id, action)); + + await this.handleGameEnd(updatedGame, chessGame); + return GameMapper.toGameDetailsV1(updatedGame); } @@ -408,6 +424,7 @@ export class GamesService { const chessGame = await handleFlagTimeoutWithLock(); if (chessGame) { + await this.handleGameEnd(chessGame); this.notifyObservers(GameMapper.toGameDetailsV1(chessGame)); } } From d9f234bf7bff2d9ba28a457d54fe082e652d47ea Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 10 Nov 2025 12:18:46 +0100 Subject: [PATCH 19/22] Fix so that clock is stopped when game ends. Also Dont decay rating unnecessarily --- libs/api-service/src/lib/Api.ts | 7 ++++- .../src/lib/games/mapper/GameMapper.ts | 6 ++--- .../src/lib/games/service/GamesService.ts | 3 ++- .../src/lib/user/service/RatingsService.ts | 27 ++++++++++--------- libs/core-game/src/lib/ChessGame.ts | 16 ++++++----- 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/libs/api-service/src/lib/Api.ts b/libs/api-service/src/lib/Api.ts index 0333fadf..438e9b91 100644 --- a/libs/api-service/src/lib/Api.ts +++ b/libs/api-service/src/lib/Api.ts @@ -22,8 +22,13 @@ const from = ( authConfig: AuthConfig, ): Api => { const processId = randomUUID(); - const ratingsService = new RatingsService(repos.rating); const lockService = new LockService(repos.cache.client); + const ratingsService = new RatingsService( + repos.rating, + repos.game, + repos.cache, + lockService, + ); const gamesService = new GamesService( repos.game, repos.move, diff --git a/libs/api-service/src/lib/games/mapper/GameMapper.ts b/libs/api-service/src/lib/games/mapper/GameMapper.ts index 36c7e830..ab70000d 100644 --- a/libs/api-service/src/lib/games/mapper/GameMapper.ts +++ b/libs/api-service/src/lib/games/mapper/GameMapper.ts @@ -145,7 +145,7 @@ const toChessGameResult = ({ : undefined; }; -const toTimeControl = (game: SelectGame): TimeControlIn => { +const toTimeControlIn = (game: SelectGame): TimeControlIn => { return { classification: game.timeControlClassification, daysPerMove: @@ -167,7 +167,7 @@ export const GameMapper = { white: undefined, black: undefined, }, - timeControl: toTimeControl(game), + timeControl: toTimeControlIn(game), actionRecord: [], result: toChessGameResult(game), status: FROM_STATUS_TYPE_MAPPING[game.status], @@ -181,7 +181,7 @@ export const GameMapper = { const players = toGamePlayers(game); return ChessGame.from({ ...toGameMeta(game), - timeControl: toTimeControl(game), + timeControl: toTimeControlIn(game), actionRecord: toGameActions(game), players, status: FROM_STATUS_TYPE_MAPPING[game.status], diff --git a/libs/api-service/src/lib/games/service/GamesService.ts b/libs/api-service/src/lib/games/service/GamesService.ts index d7194b80..7878e3dd 100644 --- a/libs/api-service/src/lib/games/service/GamesService.ts +++ b/libs/api-service/src/lib/games/service/GamesService.ts @@ -284,8 +284,9 @@ export class GamesService { }, data.side, ); + await this.gameRepository.updateGame( - gameState.id, + updatedGame.id, GameMapper.toInsertGame(updatedGame), ); return GameMapper.toGameDetailsV1(updatedGame); diff --git a/libs/api-service/src/lib/user/service/RatingsService.ts b/libs/api-service/src/lib/user/service/RatingsService.ts index bb28839f..09f56bec 100644 --- a/libs/api-service/src/lib/user/service/RatingsService.ts +++ b/libs/api-service/src/lib/user/service/RatingsService.ts @@ -220,24 +220,27 @@ export class RatingsService { } } - const newRating = RatingCalculator.decay(latestRating, processingEndTime); - await this.ratingRepository.createRating({ - playerId, - variant, - timeControlClassification, - rating: newRating.value, - deviation: newRating.deviation, - volatility: newRating.volatility, - timestamp: processingEndTime, - }); + // Decay rating if the latest rating is older than 1 day. + if (latestRating.timestamp.getTime() + 1000 * 60 * 60 * 24 < Date.now()) { + const newRating = RatingCalculator.decay(latestRating, processingEndTime); + await this.ratingRepository.createRating({ + playerId, + variant, + timeControlClassification, + rating: newRating.value, + deviation: newRating.deviation, + volatility: newRating.volatility, + timestamp: processingEndTime, + }); + } } - getRatingByPlayerId( + async getRatingByPlayerId( playerId: string, variant: GameVariantType, timeControl: TimeControlClassification, ) { - const rating = this.ratingRepository.getRatingByPlayerId( + const rating = await this.ratingRepository.getRatingByPlayerId( playerId, variant, timeControl, diff --git a/libs/core-game/src/lib/ChessGame.ts b/libs/core-game/src/lib/ChessGame.ts index c182ffd2..b6bc096b 100644 --- a/libs/core-game/src/lib/ChessGame.ts +++ b/libs/core-game/src/lib/ChessGame.ts @@ -61,6 +61,7 @@ const endGame = ( return { ...gameState, status: 'ENDED', + players: GamePlayers.from(gameState.players, resultToSet), meta: { ...gameState.meta, }, @@ -244,17 +245,14 @@ const fromGameStateInternal = ( const gameResult = evalResult(newBoard, newClock); const shouldStartGame = gameStateInternal.status === 'READY'; - const shouldEndGame = gameResult !== undefined; const newStatus = shouldStartGame ? 'IN_PROGRESS' - : shouldEndGame - ? 'ENDED' - : gameStateInternal.status; + : gameStateInternal.status; const startedAt = gameStateInternal.meta.startedAt ?? new Date(); - return fromGameStateInternal({ + const updatedGameState: GameStateInternal = { ...gameStateInternal, board: newBoard, status: newStatus, @@ -265,7 +263,13 @@ const fromGameStateInternal = ( }, result: gameResult, additionalActions: additionalActions.updateBoard(newStatus, newBoard), - }); + }; + + if (gameResult) { + return fromGameStateInternal(endGame(updatedGameState, gameResult)); + } else { + return fromGameStateInternal(updatedGameState); + } }; return { get id(): string { From f23e32bbc251b2deaa38a620e1aa1e01523483bb Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 10 Nov 2025 12:52:45 +0100 Subject: [PATCH 20/22] Display rating --- .../src/app/api/model/GameViewModel.ts | 14 ++++++- .../src/app/api/service/GameService.ts | 16 ++++++++ .../app/features/game/RemoteGameContainer.tsx | 17 +++----- .../features/game/components/PlayerInfo.tsx | 41 ++++++++++++++----- .../src/lib/games/mapper/GameMapper.ts | 8 +++- libs/core-game/src/lib/ChessClock.ts | 34 ++++++++------- libs/core-game/src/lib/ChessGame.ts | 3 +- 7 files changed, 91 insertions(+), 42 deletions(-) diff --git a/apps/web-chess/src/app/api/model/GameViewModel.ts b/apps/web-chess/src/app/api/model/GameViewModel.ts index 29e6053a..10846cbb 100644 --- a/apps/web-chess/src/app/api/model/GameViewModel.ts +++ b/apps/web-chess/src/app/api/model/GameViewModel.ts @@ -7,8 +7,18 @@ export type GameViewModel = { result?: GameResultV1; status: GameStatusTypeV1; players: { - white?: { username: string; avatar?: string }; - black?: { username: string; avatar?: string }; + white?: { + username: string; + avatar?: string; + rating?: string; + ratingDiff?: string; + }; + black?: { + username: string; + avatar?: string; + rating?: string; + ratingDiff?: string; + }; }; startedAt?: Date; clock?: CountdownClock; diff --git a/apps/web-chess/src/app/api/service/GameService.ts b/apps/web-chess/src/app/api/service/GameService.ts index 27ff82a5..8fa21a8a 100644 --- a/apps/web-chess/src/app/api/service/GameService.ts +++ b/apps/web-chess/src/app/api/service/GameService.ts @@ -46,6 +46,18 @@ export class GameService { } toGameViewModel(gameDetails: GameDetailsV1): GameViewModel { + const whiteRatingDiff = gameDetails.players.white?.ratingDiff; + const whiteRatingDiffText = whiteRatingDiff + ? whiteRatingDiff > 0 + ? `+ ${whiteRatingDiff}` + : `- ${Math.abs(whiteRatingDiff)}` + : undefined; + const blackRatingDiff = gameDetails.players.black?.ratingDiff; + const blackRatingDiffText = blackRatingDiff + ? blackRatingDiff > 0 + ? `+ ${blackRatingDiff}` + : `- ${Math.abs(blackRatingDiff)}` + : undefined; return { status: gameDetails.status, moves: gameDetails.moves.map((m) => Move.fromUci(m.uci)), @@ -62,12 +74,16 @@ export class GameService { ? { username: gameDetails.players.white.name, avatar: undefined, + rating: gameDetails.players.white.rating?.toString(), + ratingDiff: whiteRatingDiffText, } : undefined, black: gameDetails.players.black ? { username: gameDetails.players.black.name, avatar: undefined, + rating: gameDetails.players.black.rating?.toString(), + ratingDiff: blackRatingDiffText, } : undefined, }, diff --git a/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx b/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx index a3055bf9..d36a4f61 100644 --- a/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx +++ b/apps/web-chess/src/app/features/game/RemoteGameContainer.tsx @@ -34,19 +34,10 @@ export const RemoteGameContainer = ({ isPeeking, } = usePeekBoardState(chessboard); const { auth } = useAuth(); - const { - players, - playerSide, - result, - isReadOnly, - actionOptions, - clock, - status, - } = gameState; + const { players, playerSide, result, isReadOnly, actionOptions, clock } = + gameState; orientation = playerSide !== 'spectator' ? playerSide : orientation; - const isInProgress = status === 'IN_PROGRESS'; - const blackPlayer = { ...players.black, color: Color.Black }; const whitePlayer = { ...players.white, color: Color.White }; const currentOrientation = orientation || Color.White; @@ -74,6 +65,8 @@ export const RemoteGameContainer = ({ username={topPlayer?.username} avatar={topPlayer?.avatar} color={topPlayer.color} + rating={topPlayer?.rating} + ratingDiff={topPlayer?.ratingDiff} isPlayerTurn={chessboard.position.turn === topPlayer.color} isLoading={isLoadingInitial} /> @@ -100,6 +93,8 @@ export const RemoteGameContainer = ({ username={bottomPlayer?.username} avatar={bottomPlayer?.avatar} color={bottomPlayer.color} + rating={bottomPlayer?.rating} + ratingDiff={bottomPlayer?.ratingDiff} isPlayerTurn={chessboard.position.turn === bottomPlayer.color} isLoading={isLoadingInitial} /> diff --git a/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx b/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx index c5c01177..f5ce3c80 100644 --- a/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx +++ b/apps/web-chess/src/app/features/game/components/PlayerInfo.tsx @@ -1,5 +1,5 @@ import { Color } from '@michess/core-board'; -import { Avatar, Badge, Flex, Skeleton, Text } from '@radix-ui/themes'; +import { Avatar, Badge, Box, Flex, Skeleton, Text } from '@radix-ui/themes'; import React from 'react'; import { CountdownClock } from '../../../api/model/CountdownClock'; import { Clock } from './Clock'; @@ -13,6 +13,8 @@ type PlayerInfoProps = { isPlayerTurn?: boolean; isLoading?: boolean; clock?: CountdownClock; + rating?: string; + ratingDiff?: string; }; export const PlayerInfo: React.FC = ({ @@ -23,6 +25,8 @@ export const PlayerInfo: React.FC = ({ avatar, isPlayerTurn = false, clock, + rating, + ratingDiff, isLoading, }) => { const getInitials = (name: string): string => { @@ -52,16 +56,31 @@ export const PlayerInfo: React.FC = ({ - - {displayUsername} - + + + {displayUsername} + + + {rating !== undefined ? ( + {rating} + ) : undefined} + {ratingDiff !== undefined ? ( + + {ratingDiff} + + ) : undefined} + + diff --git a/libs/api-service/src/lib/games/mapper/GameMapper.ts b/libs/api-service/src/lib/games/mapper/GameMapper.ts index ab70000d..51855ea8 100644 --- a/libs/api-service/src/lib/games/mapper/GameMapper.ts +++ b/libs/api-service/src/lib/games/mapper/GameMapper.ts @@ -265,7 +265,9 @@ export const GameMapper = { white: game.players.white ? { id: game.players.white.id, - rating: game.players.white.rating?.value, + rating: game.players.white.rating?.value + ? Math.round(game.players.white.rating.value) + : undefined, ratingDiff: game.players.white.ratingDiff, name: game.players.white.name, } @@ -273,7 +275,9 @@ export const GameMapper = { black: game.players.black ? { id: game.players.black.id, - rating: game.players.black.rating?.value, + rating: game.players.black.rating?.value + ? Math.round(game.players.black.rating.value) + : undefined, ratingDiff: game.players.black.ratingDiff, name: game.players.black.name, } diff --git a/libs/core-game/src/lib/ChessClock.ts b/libs/core-game/src/lib/ChessClock.ts index d2e01a82..1092d06f 100644 --- a/libs/core-game/src/lib/ChessClock.ts +++ b/libs/core-game/src/lib/ChessClock.ts @@ -43,21 +43,25 @@ export class ChessClock = Maybe> { if (gameState.timeControl.classification !== 'no_clock') { const initialTurn = gameState.initialPosition.turn; const clockSettings = ClockSettings.fromGameState(gameState); - return gameState.movesRecord.reduce>( - (clock, moveRecord, index) => { - return clock.hit( - initialTurn === Color.White - ? index % 2 === 0 - ? Color.White - : Color.Black - : index % 2 === 0 - ? Color.Black - : Color.White, - moveRecord.timestamp, - ); - }, - ChessClock.from(clockSettings), - ); + const clockAfterMoves = gameState.movesRecord.reduce< + ChessClock + >((clock, moveRecord, index) => { + return clock.hit( + initialTurn === Color.White + ? index % 2 === 0 + ? Color.White + : Color.Black + : index % 2 === 0 + ? Color.Black + : Color.White, + moveRecord.timestamp, + ); + }, ChessClock.from(clockSettings)); + if (gameState.result?.timestamp) { + return clockAfterMoves.pause(gameState.result.timestamp); + } else { + return clockAfterMoves; + } } else { return undefined; } diff --git a/libs/core-game/src/lib/ChessGame.ts b/libs/core-game/src/lib/ChessGame.ts index b6bc096b..3df1ed60 100644 --- a/libs/core-game/src/lib/ChessGame.ts +++ b/libs/core-game/src/lib/ChessGame.ts @@ -451,13 +451,14 @@ const from = (chessGameIn: ChessGameIn): ChessGame => { startedAt: chessGameIn.startedAt, isPrivate: chessGameIn.isPrivate, variant: chessGameIn.variant, - players: GamePlayers.from(chessGameIn.players), + players: GamePlayers.from(chessGameIn.players, chessGameIn.result), status: chessGameIn.status, result: chessGameIn.result, resultStr: ChessGameResult.toResultString(chessGameIn.result), initialPosition: FenParser.toChessPosition( chessGameIn.initialPosition ?? FenStr.standardInitial(), ), + actionRecord: chessGameIn.actionRecord, movesRecord: chessGameIn.movesRecord, timeControl, From 66aa4931e95c8f28a4b6639287045ee8790c5d16 Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 10 Nov 2025 12:59:31 +0100 Subject: [PATCH 21/22] Show rating of player in lobby --- apps/web-chess/src/app/features/lobby/GameLobby.tsx | 3 ++- libs/api-service/src/lib/games/mapper/GameMapper.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web-chess/src/app/features/lobby/GameLobby.tsx b/apps/web-chess/src/app/features/lobby/GameLobby.tsx index 2af091c8..bc8a1ab6 100644 --- a/apps/web-chess/src/app/features/lobby/GameLobby.tsx +++ b/apps/web-chess/src/app/features/lobby/GameLobby.tsx @@ -84,7 +84,8 @@ export const GameLobby: React.FC = ({ onCreateGame, onJoinGame }) => { - {game.opponent.name} + {game.opponent.name}{' '} + {game.opponent.rating ? `(${game.opponent.rating})` : ''} diff --git a/libs/api-service/src/lib/games/mapper/GameMapper.ts b/libs/api-service/src/lib/games/mapper/GameMapper.ts index 51855ea8..926bc8de 100644 --- a/libs/api-service/src/lib/games/mapper/GameMapper.ts +++ b/libs/api-service/src/lib/games/mapper/GameMapper.ts @@ -128,7 +128,7 @@ const toPlayerInfoV1 = (player: PlayerInfo): PlayerInfoV1 => { return { id: player.id, name: player.name, - rating: player.rating?.value, + rating: player.rating?.value ? Math.round(player.rating.value) : undefined, ratingDiff: player.ratingDiff, }; }; From b463ae0c8b03e16cbd7dcab906f87e84e80c321d Mon Sep 17 00:00:00 2001 From: Michael Nilsson Date: Mon, 10 Nov 2025 13:03:56 +0100 Subject: [PATCH 22/22] Remove commented code --- libs/core-game/src/lib/model/ChessGameIn.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/libs/core-game/src/lib/model/ChessGameIn.ts b/libs/core-game/src/lib/model/ChessGameIn.ts index 7377b44b..626ba7c8 100644 --- a/libs/core-game/src/lib/model/ChessGameIn.ts +++ b/libs/core-game/src/lib/model/ChessGameIn.ts @@ -1,14 +1,3 @@ -// export type GameState = GameMeta & { -// players: GamePlayers; -// status: GameStatusType; -// result: Maybe; -// resultStr: string; -// initialPosition: ChessPosition; -// actionRecord: GameAction[]; -// movesRecord: MoveRecord[]; -// timeControl: TimeControl; -// }; - import { Maybe } from '@michess/common-utils'; import { FenStr, MoveRecord } from '@michess/core-board'; import { RatingSnapshot } from '@michess/core-rating';