@pithy-sh/rating 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/algorithms.md +43 -0
- package/package.json +50 -0
- package/pithy.manifest.json +42 -0
- package/src/algorithm/algorithm.ts +75 -0
- package/src/algorithm/builtins/elo.ts +107 -0
- package/src/algorithm/builtins/glicko.ts +179 -0
- package/src/algorithm/builtins/trueskill.ts +332 -0
- package/src/algorithm/builtins.ts +19 -0
- package/src/algorithm/registry.ts +35 -0
- package/src/capability.ts +77 -0
- package/src/cloudflare-test.d.ts +14 -0
- package/src/config/config.ts +224 -0
- package/src/data/rating.ts +39 -0
- package/src/data/store.ts +73 -0
- package/src/data/tables.ts +27 -0
- package/src/error/errors.ts +130 -0
- package/src/experience/xp.ts +36 -0
- package/src/http/guard.ts +39 -0
- package/src/http/routes.ts +137 -0
- package/src/http/schemas.ts +65 -0
- package/src/index.ts +42 -0
- package/src/migrations/0001_rating.ts +49 -0
- package/src/record/record.ts +138 -0
- package/src/seeds/example.ts +59 -0
- package/src/version.generated.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/rating
|
|
2
|
+
|
|
3
|
+
Skill and experience for your games. Two numbers per player, per pool: a skill rating that moves both ways with every result and feeds matchmaking, and an experience total that only ever rises and drives rank.
|
|
4
|
+
|
|
5
|
+
They are separate on purpose. One number cannot both rank a player fairly and reward them for turning up — a ladder that pays out for playing stops estimating strength, and a rating that only estimates strength has nothing to show a player who is not winning yet.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add rating
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/rating](https://pithy.sh/docs/capabilities/rating).** Overview, adding it, using it, and the reference: the algorithms, pools.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Choosing a rating algorithm
|
|
2
|
+
|
|
3
|
+
_The reader's version of this page is [pithy.sh/docs/capabilities/rating/algorithms](https://pithy.sh/docs/capabilities/rating/algorithms). This copy ships in the package because `packages/rating/pithy.manifest.json` sends an adopter to it by name._
|
|
4
|
+
|
|
5
|
+
The rating tracker holds two numbers per player per pool. Skill rating (MMR) is the matchmaking input — it moves up and down with each result, weighted by opponent strength, and can be hidden from players. Experience (XP) is the visible progression — a monotonic total that only ever rises and drives rank and level. This document is about the first number: which algorithm computes it.
|
|
6
|
+
|
|
7
|
+
Three algorithms ship behind one seam. Each declares the player counts it supports, and the tracker rejects at assembly a game that wires a 1v1-only algorithm to an N-player game — the same way a bad game config fails on deploy rather than at 3am. You choose one per game and set its pool; you can register your own with `registerRatingAlgorithm`.
|
|
8
|
+
|
|
9
|
+
## The one-line answer
|
|
10
|
+
|
|
11
|
+
Rating a 1v1 game and you want it simple and transparent? Use `elo`. Rating a 1v1 game where players play infrequently and you care about rating accuracy? Use `glicko`. Rating anything else — free-for-alls, teams, any count above two? Use `trueskill`. It is the only built-in that fits this package's N-player games.
|
|
12
|
+
|
|
13
|
+
## `elo`
|
|
14
|
+
|
|
15
|
+
One number per player, and nothing else. A win moves both players by `K × (actual − expected)`, where the expected score comes from the rating gap. Beating a stronger player earns more than beating a weaker one; that is the whole model. `K` (the K-factor, default 32) is the single dial — larger `K` reacts faster and swings harder, smaller `K` is steadier.
|
|
16
|
+
|
|
17
|
+
Elo is 1v1 only. It has no notion of uncertainty, so a brand-new player and a veteran with a thousand games are treated identically at the same rating — the new player's number just takes many games to find its level. Reach for Elo when you want a rating players can understand at a glance and you do not need fast convergence.
|
|
18
|
+
|
|
19
|
+
Supports: 2 players. Skill number: the rating itself.
|
|
20
|
+
|
|
21
|
+
## `glicko` (Glicko-2)
|
|
22
|
+
|
|
23
|
+
Elo's successor, and still 1v1. Alongside the rating it tracks a rating deviation (RD) — how confident the system is — and a volatility — how erratic the player's recent results have been. A result from a high-RD player moves their rating a lot and their opponent's a little; RD shrinks as they play and grows back while they are idle. That is why Glicko re-converges fast for infrequent players: a returning player is treated as uncertain and finds their level in a handful of games rather than dozens.
|
|
24
|
+
|
|
25
|
+
The volatility is solved each game by an iteration (the crux of Glicko-2) and is constrained by the system constant `τ` (default 0.5) — smaller `τ` damps volatility swings. The skill number this tracker exposes and buckets on is `rating − 2·RD`, a conservative estimate that treats an uncertain player as weaker until they have proven otherwise, which keeps new and returning players out of lopsided matches.
|
|
26
|
+
|
|
27
|
+
Supports: 2 players. Skill number: `rating − 2·RD`.
|
|
28
|
+
|
|
29
|
+
## `trueskill`
|
|
30
|
+
|
|
31
|
+
The Bayesian one, and the only built-in that rates more than two players. Each player is a Gaussian belief — a mean skill `μ` and an uncertainty `σ`. A game is evidence that shifts every participant's belief: winners' `μ` rises, losers' falls, and everyone's `σ` shrinks toward certainty. It handles 1v1, N-player free-for-alls (each player is a team of one), and teams (a team's skill is the sum of its members', and the result is distributed back across them).
|
|
32
|
+
|
|
33
|
+
Matchmaking buckets on `μ − 3·σ`, a conservative rank that stays low until the system is confident. Constants: `β` (skill class width, default `σ0/2`) sets how much a single game can prove, `τ` (dynamics, default `σ0/100`) lets ratings drift over time so a long-dormant player is not frozen, and the draw probability shapes how a tie is interpreted. This built-in uses the standard sequential approximation for more than two teams — see the source for the exact method.
|
|
34
|
+
|
|
35
|
+
Supports: 2 or more players; teams. Skill number: `μ − 3·σ`.
|
|
36
|
+
|
|
37
|
+
## Pools
|
|
38
|
+
|
|
39
|
+
An algorithm rates a pool, not a game directly. A pool is a named bucket of ratings; a game reads and writes one. Point several games at one pool (`global`) for a single cross-game rating, or give each game its own pool (the default — the pool defaults to the game key) for independent ladders. A pool is rated by a single algorithm; matchmaking buckets on the configured pool's skill number.
|
|
40
|
+
|
|
41
|
+
## Registering your own
|
|
42
|
+
|
|
43
|
+
The seam is `RatingAlgorithm` (`@pithy-sh/rating/src/algorithm/algorithm`): declare an `id`, a `params` schema with defaults, a `state` schema, the player counts you support, and pure `initial` / `update` / `skill` functions. Register it with `registerRatingAlgorithm` before the capability assembles, then name its `id` in a game. The tracker validates your params and player bounds exactly as it does the built-ins.
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/rating",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/rating"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"docs",
|
|
14
|
+
"!src/**/*.test.*"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./src/*": "./src/*.ts"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:node": "vitest run --project=node",
|
|
28
|
+
"test:workers": "vitest run --project=workers",
|
|
29
|
+
"clean": "rm -rf dist .turbo",
|
|
30
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
34
|
+
"@hono/zod-validator": "^0.9.0",
|
|
35
|
+
"@pithy-sh/core": "workspace:*",
|
|
36
|
+
"hono": "^4.13.2",
|
|
37
|
+
"kysely": "^0.29.0",
|
|
38
|
+
"zod": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
42
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
43
|
+
"@types/node": "^22.15.0",
|
|
44
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
45
|
+
"kysely-d1": "^0.4.0",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vitest": "^4.1.0",
|
|
48
|
+
"wrangler": "^4.115.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rating",
|
|
3
|
+
"package": "@pithy-sh/rating",
|
|
4
|
+
"requiredBindings": [{ "type": "d1", "name": "DB" }],
|
|
5
|
+
"peerCapabilities": [],
|
|
6
|
+
"optionalCapabilities": ["auth"],
|
|
7
|
+
"migrationNamespace": "rating",
|
|
8
|
+
"whenToEnable": "Two numbers per player, per pool, in your own D1: a skill rating (MMR) that moves both ways with every result and feeds matchmaking, and an experience total (XP) that only ever rises and drives rank. They are separate on purpose — one number cannot both rank a player fairly and reward them for turning up. The algorithm is a choice rather than a hardcode: `elo` (1v1, transparent, one tunable), `glicko` (Glicko-2, 1v1, carrying an uncertainty term so a player returning on stale form is not rated as if they never left), and `trueskill` (any roster, and the only one that rates teams) ship built in, and registerRatingAlgorithm takes your own. Wiring a 1v1-only algorithm to a four-player game fails on deploy, not on the first recorded result. Pools are named, so several games can share one ladder or each can keep its own — and a pool is what @pithy-sh/matchmaking buckets its open queue on, which makes this the skill source that capability needs. Recording an outcome is server-authoritative by default and needs the rating:record scope, so a device cannot report that it won. This is the input system @pithy-sh/leaderboard deliberately left out: a leaderboard ranks what has happened, a rating estimates what will. Ratings bind to an authenticated player, so add auth too — without it every route is denied.",
|
|
9
|
+
"scaffold": [
|
|
10
|
+
"Add a `rating({ games: [...] })` block to pithy.config.ts and declare at least one game.",
|
|
11
|
+
"Give each game a `key` and an `algorithm` (`elo`, `glicko`, `trueskill`, or one you registered), and optionally `players`, `teams`, a shared `pool`, an `xp` award per outcome, and a `levels` ladder. `elo` and `glicko` rate 1v1 only; `trueskill` rates any roster and is the only one that rates teams.",
|
|
12
|
+
"Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
|
|
13
|
+
"Run `pithy migrate` to create pithy_rating_ratings.",
|
|
14
|
+
"Add `@pithy-sh/auth` if it is not already installed. A rating belongs to an authenticated player; without auth every route is denied.",
|
|
15
|
+
"Mint a token carrying the `rating:record` scope for your trusted server, and record outcomes with it. Never put that scope on a player's token.",
|
|
16
|
+
"Call `registerRatingAlgorithm(yourAlgorithm)` in your worker entry, before the capability assembles, if none of the three built-ins fit. An unknown algorithm id fails at assembly.",
|
|
17
|
+
"Add `@pithy-sh/matchmaking` and point a game's `skillPool` at a pool declared here to bucket its open queue on skill.",
|
|
18
|
+
"Read packages/rating/docs/algorithms.md before choosing. A stored rating means nothing under a different model, so the choice is immutable in practice."
|
|
19
|
+
],
|
|
20
|
+
"configOptions": [
|
|
21
|
+
{
|
|
22
|
+
"key": "games",
|
|
23
|
+
"default": [{ "key": "duel", "algorithm": "elo", "xp": { "win": 20, "draw": 10, "loss": 0 } }],
|
|
24
|
+
"describe": "Every rated game this app runs. Replace this example — a 1v1 duel rated by Elo, in a pool named after the key, awarding XP on top. `key` is a URL path segment and the outcome's game reference; `algorithm` names a built-in (`elo`, `glicko`, `trueskill`) or one you registered; `pool` shares a ladder across games and defaults to the key. At least one game is required."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"key": "serverAuthoritative",
|
|
28
|
+
"default": true,
|
|
29
|
+
"describe": "Require the record scope to post an outcome. On by default: a rating a client can write is a rating a client can invent, and the whole ladder is downstream of it. Turn it off only if you accept that a player's device can report that it won."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"key": "basePath",
|
|
33
|
+
"default": "/rating",
|
|
34
|
+
"describe": "Where the rating routes mount. Outcomes are recorded at POST `<basePath>/games/:game/outcomes`, and a standing is read under `<basePath>/games/:game`."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"key": "recordScope",
|
|
38
|
+
"default": "rating:record",
|
|
39
|
+
"describe": "The scope a caller must hold to record an outcome while `serverAuthoritative` is on. Mint it for your trusted server's token and never for a player's."
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The rating-algorithm seam — the one contract that keeps the tracker's storage, config, and HTTP
|
|
8
|
+
* surface generic while the *math* of turning a game's result into new numbers stays pluggable.
|
|
9
|
+
*
|
|
10
|
+
* It is the deliberate mirror of `@pithy-sh/multiplayer`'s game-model registry (`game/model.ts`): a
|
|
11
|
+
* `RatingAlgorithm` is looked up by `id` from a {@link registerRatingAlgorithm registry}, it declares the
|
|
12
|
+
* player counts it supports, and the config layer rejects at assembly a game that wires a 1v1-only
|
|
13
|
+
* algorithm (`elo`/`glicko`) to an N-player game — the same shape as multiplayer's `validateGames`.
|
|
14
|
+
*
|
|
15
|
+
* Three built-ins ship (`elo`, `glicko`, `trueskill`); an adopter registers their own the same way. The
|
|
16
|
+
* seam is code, not data: an algorithm's logic is imported into the worker bundle, while only its per-
|
|
17
|
+
* player {@link RatingAlgorithm.state state} (a serializable, Zod-validated blob) lives in D1.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One player's entry into a rated result: who they are and their rating state *before* the game. */
|
|
21
|
+
export interface RatingEntry<State = unknown> {
|
|
22
|
+
/** The player's authenticated user id (the `pithy_auth_users.id` UUID from `AuthContext`). */
|
|
23
|
+
playerId: string;
|
|
24
|
+
/** The player's current rating state in the game's pool — {@link RatingAlgorithm.initial} for a newcomer. */
|
|
25
|
+
state: State;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The result of one rated game, in the model-neutral shape every algorithm consumes. Placement is a
|
|
30
|
+
* finishing place per player: `1` is the winner, ties share a place. For a 1v1 win, the winner is place
|
|
31
|
+
* `1` and the loser place `2`; a draw makes both place `1`. Free-for-all and team formats express their
|
|
32
|
+
* full ordering the same way.
|
|
33
|
+
*/
|
|
34
|
+
export interface RatedOutcome {
|
|
35
|
+
/** Each player's finishing place (1-based). Equal values are a tie. Every entered player must appear. */
|
|
36
|
+
ranks: Record<string, number>;
|
|
37
|
+
/**
|
|
38
|
+
* Optional team grouping: player id → team id. Present only for a team format (TrueSkill), where a
|
|
39
|
+
* team's players share a placement and the update pools their skill. Absent for 1v1 / free-for-all.
|
|
40
|
+
*/
|
|
41
|
+
teams?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One rating algorithm — a pluggable implementation of "given everyone's state and how the game finished,
|
|
46
|
+
* what is everyone's new state?" `State` is the per-player blob persisted between games; `Params` is the
|
|
47
|
+
* algorithm's declarative tuning (K-factor, volatility constraint, prior μ/σ), validated at assembly and
|
|
48
|
+
* threaded into every call — the exact analog of a multiplayer model's `rules` block.
|
|
49
|
+
*/
|
|
50
|
+
export interface RatingAlgorithm<State = unknown, Params = unknown> {
|
|
51
|
+
/** The discriminator that matches a game config's `algorithm`. Unique per registered algorithm. */
|
|
52
|
+
id: string;
|
|
53
|
+
/** Validates and types the algorithm's tuning block (`algoParams` in config). Must supply defaults. */
|
|
54
|
+
params: z.ZodType<Params>;
|
|
55
|
+
/** Validates and types the persisted per-player state — round-tripped through D1 on every read. */
|
|
56
|
+
state: z.ZodType<State>;
|
|
57
|
+
/** The fewest players a single rated game supports. `elo`/`glicko` = 2; `trueskill` = 2. */
|
|
58
|
+
minPlayers: number;
|
|
59
|
+
/** The most players a single rated game supports. `elo`/`glicko` = 2; `trueskill` = `Infinity`. */
|
|
60
|
+
maxPlayers: number;
|
|
61
|
+
/** Whether the algorithm can rate team formats (a `teams` grouping). Only `trueskill`. */
|
|
62
|
+
supportsTeams: boolean;
|
|
63
|
+
/** A brand-new player's starting state, from the (defaulted) params. */
|
|
64
|
+
initial(params: Params): State;
|
|
65
|
+
/**
|
|
66
|
+
* Recompute every participant's state from one game's outcome. Pure — no clock, no storage, no RNG.
|
|
67
|
+
* Returns the new state for every entered player, keyed by `playerId`; the caller persists each.
|
|
68
|
+
*/
|
|
69
|
+
update(params: Params, entries: readonly RatingEntry<State>[], outcome: RatedOutcome): Record<string, State>;
|
|
70
|
+
/**
|
|
71
|
+
* The single comparable number the API exposes and matchmaking buckets on. Conservative where the
|
|
72
|
+
* algorithm models uncertainty: `elo` = rating; `glicko` = rating − 2·RD; `trueskill` = μ − 3·σ.
|
|
73
|
+
*/
|
|
74
|
+
skill(params: Params, state: State): number;
|
|
75
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { RatingUnsupportedPlayerCountError } from "../../error/errors";
|
|
6
|
+
import type { RatedOutcome, RatingAlgorithm, RatingEntry } from "../algorithm";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Elo — the classic 1v1 rating. One number per player, no modeled uncertainty: a game nudges the pair
|
|
10
|
+
* toward or away from each other by `k · (actual − expected)`, where `expected` is the logistic of the
|
|
11
|
+
* rating gap over a 400-point scale. Deterministic and symmetric — the winner's gain equals the loser's
|
|
12
|
+
* loss. 1v1 only; N-player and team formats use `trueskill`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One Elo player's persisted state: a single rating number, round-tripped through D1 between games. */
|
|
16
|
+
export const EloState = z
|
|
17
|
+
.object({
|
|
18
|
+
rating: z
|
|
19
|
+
.number()
|
|
20
|
+
.describe("The player's current Elo rating — higher is stronger. Newcomers start at `initialRating`."),
|
|
21
|
+
})
|
|
22
|
+
.describe("The per-player Elo state persisted between games: just the rating number.");
|
|
23
|
+
export type EloState = z.output<typeof EloState>;
|
|
24
|
+
|
|
25
|
+
/** Elo's declarative tuning: the K-factor (volatility) and the newcomer's starting rating. */
|
|
26
|
+
export const EloParams = z
|
|
27
|
+
.object({
|
|
28
|
+
k: z
|
|
29
|
+
.number()
|
|
30
|
+
.positive()
|
|
31
|
+
.default(32)
|
|
32
|
+
.describe(
|
|
33
|
+
"The K-factor — the maximum rating swing from a single game. Higher reacts faster; lower is more stable. Default 32.",
|
|
34
|
+
),
|
|
35
|
+
initialRating: z
|
|
36
|
+
.number()
|
|
37
|
+
.default(1500)
|
|
38
|
+
.describe("The rating a brand-new player enters the pool with. The conventional Elo midpoint is 1500."),
|
|
39
|
+
})
|
|
40
|
+
.describe(
|
|
41
|
+
"Elo's tuning block: K-factor and starting rating. Every field defaults, so `EloParams.parse({})` yields the standard tuning.",
|
|
42
|
+
);
|
|
43
|
+
export type EloParams = z.output<typeof EloParams>;
|
|
44
|
+
|
|
45
|
+
/** The 1v1 score of `a` against `b` from their finishing places: lower place is better. Win 1, draw 0.5, loss 0. */
|
|
46
|
+
function scoreFromRanks(aRank: number, bRank: number): number {
|
|
47
|
+
if (aRank < bRank) return 1;
|
|
48
|
+
if (aRank > bRank) return 0;
|
|
49
|
+
return 0.5;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The classic Elo algorithm — a single instance registered by `id`, not a factory. */
|
|
53
|
+
export const elo: RatingAlgorithm<EloState, EloParams> = {
|
|
54
|
+
id: "elo",
|
|
55
|
+
params: EloParams,
|
|
56
|
+
state: EloState,
|
|
57
|
+
minPlayers: 2,
|
|
58
|
+
maxPlayers: 2,
|
|
59
|
+
supportsTeams: false,
|
|
60
|
+
|
|
61
|
+
initial(params: EloParams): EloState {
|
|
62
|
+
return { rating: params.initialRating };
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
update(
|
|
66
|
+
params: EloParams,
|
|
67
|
+
entries: readonly RatingEntry<EloState>[],
|
|
68
|
+
outcome: RatedOutcome,
|
|
69
|
+
): Record<string, EloState> {
|
|
70
|
+
if (entries.length !== 2) {
|
|
71
|
+
throw new RatingUnsupportedPlayerCountError({
|
|
72
|
+
detail: `elo.update expected exactly 2 entries, got ${entries.length}.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const a = entries[0];
|
|
76
|
+
const b = entries[1];
|
|
77
|
+
if (a === undefined || b === undefined) {
|
|
78
|
+
throw new RatingUnsupportedPlayerCountError({
|
|
79
|
+
detail: `elo.update expected exactly 2 entries, got ${entries.length}.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const ratingA = a.state.rating;
|
|
83
|
+
const ratingB = b.state.rating;
|
|
84
|
+
|
|
85
|
+
const expectedA = 1 / (1 + 10 ** ((ratingB - ratingA) / 400));
|
|
86
|
+
const expectedB = 1 - expectedA;
|
|
87
|
+
|
|
88
|
+
const rankA = outcome.ranks[a.playerId];
|
|
89
|
+
const rankB = outcome.ranks[b.playerId];
|
|
90
|
+
if (rankA === undefined || rankB === undefined) {
|
|
91
|
+
throw new RatingUnsupportedPlayerCountError({
|
|
92
|
+
detail: "elo.update requires a rank for each of its two players.",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const scoreA = scoreFromRanks(rankA, rankB);
|
|
96
|
+
const scoreB = scoreFromRanks(rankB, rankA);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
[a.playerId]: { rating: ratingA + params.k * (scoreA - expectedA) },
|
|
100
|
+
[b.playerId]: { rating: ratingB + params.k * (scoreB - expectedB) },
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
skill(_params: EloParams, state: EloState): number {
|
|
105
|
+
return state.rating;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { RatingAlgorithm, RatingEntry } from "../algorithm";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Glicko-2 — Mark Glickman's rating system, 1v1 only. A player carries a `rating` (the Elo-scale
|
|
10
|
+
* estimate), a `rd` (rating deviation — the uncertainty band around that estimate), and a `vol`
|
|
11
|
+
* (volatility — how erratic the player's results are). A game shrinks `rd` (we learn), and the
|
|
12
|
+
* outcome plus expectation nudges `rating`. All the interesting math lives in {@link glicko.update}.
|
|
13
|
+
*
|
|
14
|
+
* Reference: Glickman, "Example of the Glicko-2 system" (glicko.net/glicko/glicko2.pdf). Constants:
|
|
15
|
+
* the Glicko-2 internal scale factor is 173.7178 and the anchor rating is 1500.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The Glicko-2 scale factor converting between the public Elo scale and the internal (μ, φ) scale. */
|
|
19
|
+
const SCALE = 173.7178;
|
|
20
|
+
/** The public-scale anchor rating that maps to internal μ = 0. */
|
|
21
|
+
const ANCHOR = 1500;
|
|
22
|
+
/** Convergence tolerance for the volatility (Illinois) iteration. */
|
|
23
|
+
const CONVERGENCE = 1e-6;
|
|
24
|
+
|
|
25
|
+
/** One player's persisted Glicko-2 state: their rating, its deviation, and their volatility. */
|
|
26
|
+
export const GlickoState = z
|
|
27
|
+
.object({
|
|
28
|
+
rating: z.number().describe("The player's Glicko rating on the public Elo scale (anchored at 1500)."),
|
|
29
|
+
rd: z
|
|
30
|
+
.number()
|
|
31
|
+
.positive()
|
|
32
|
+
.describe("Rating deviation — the uncertainty band around the rating; shrinks with each game."),
|
|
33
|
+
vol: z
|
|
34
|
+
.number()
|
|
35
|
+
.positive()
|
|
36
|
+
.describe("Volatility — how erratic the player's results are; governs how fast rating can move."),
|
|
37
|
+
})
|
|
38
|
+
.describe("A single player's Glicko-2 rating state, persisted between games.");
|
|
39
|
+
export type GlickoState = z.output<typeof GlickoState>;
|
|
40
|
+
|
|
41
|
+
/** The Glicko-2 tuning block. Every field defaults, so `GlickoParams.parse({})` yields the standard defaults. */
|
|
42
|
+
export const GlickoParams = z
|
|
43
|
+
.object({
|
|
44
|
+
tau: z
|
|
45
|
+
.number()
|
|
46
|
+
.positive()
|
|
47
|
+
.default(0.5)
|
|
48
|
+
.describe("System constant τ, constraining volatility change over time (typical 0.3–1.2; smaller = steadier)."),
|
|
49
|
+
initialRating: z.number().default(1500).describe("A newcomer's starting rating on the public Elo scale."),
|
|
50
|
+
initialRd: z
|
|
51
|
+
.number()
|
|
52
|
+
.positive()
|
|
53
|
+
.default(350)
|
|
54
|
+
.describe("A newcomer's starting rating deviation (maximal uncertainty)."),
|
|
55
|
+
initialVol: z.number().positive().default(0.06).describe("A newcomer's starting volatility."),
|
|
56
|
+
})
|
|
57
|
+
.describe("Glicko-2 tuning parameters, each defaulted to Glickman's recommended values.");
|
|
58
|
+
export type GlickoParams = z.output<typeof GlickoParams>;
|
|
59
|
+
|
|
60
|
+
/** g(φ): the impact-of-opponent-uncertainty factor. A more uncertain opponent counts for less. */
|
|
61
|
+
function g(phi: number): number {
|
|
62
|
+
return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** E(μ, μ_j, φ_j): the expected score of the player against the opponent. */
|
|
66
|
+
function expectedScore(mu: number, muJ: number, phiJ: number): number {
|
|
67
|
+
return 1 / (1 + Math.exp(-g(phiJ) * (mu - muJ)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Solve the new volatility σ' via the Illinois (regula-falsi) algorithm on Glickman's f(x).
|
|
72
|
+
* `phi`, `v`, `delta` are already on the internal scale; `sigma` is the current volatility; `tau` the constant.
|
|
73
|
+
*/
|
|
74
|
+
function newVolatility(sigma: number, phi: number, v: number, delta: number, tau: number): number {
|
|
75
|
+
const a = Math.log(sigma * sigma);
|
|
76
|
+
const deltaSq = delta * delta;
|
|
77
|
+
const phiSq = phi * phi;
|
|
78
|
+
const tauSq = tau * tau;
|
|
79
|
+
|
|
80
|
+
const f = (x: number): number => {
|
|
81
|
+
const ex = Math.exp(x);
|
|
82
|
+
const num = ex * (deltaSq - phiSq - v - ex);
|
|
83
|
+
const den = 2 * (phiSq + v + ex) * (phiSq + v + ex);
|
|
84
|
+
return num / den - (x - a) / tauSq;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let A = a;
|
|
88
|
+
let B: number;
|
|
89
|
+
if (deltaSq > phiSq + v) {
|
|
90
|
+
B = Math.log(deltaSq - phiSq - v);
|
|
91
|
+
} else {
|
|
92
|
+
let k = 1;
|
|
93
|
+
while (f(a - k * tau) < 0) {
|
|
94
|
+
k++;
|
|
95
|
+
}
|
|
96
|
+
B = a - k * tau;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let fA = f(A);
|
|
100
|
+
let fB = f(B);
|
|
101
|
+
while (Math.abs(B - A) > CONVERGENCE) {
|
|
102
|
+
const C = A + ((A - B) * fA) / (fB - fA);
|
|
103
|
+
const fC = f(C);
|
|
104
|
+
if (fC * fB <= 0) {
|
|
105
|
+
A = B;
|
|
106
|
+
fA = fB;
|
|
107
|
+
} else {
|
|
108
|
+
fA = fA / 2;
|
|
109
|
+
}
|
|
110
|
+
B = C;
|
|
111
|
+
fB = fC;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Math.exp(A / 2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Run the Glicko-2 single-game update for one player (rating r, rd, vol) against one opponent, given score s. */
|
|
118
|
+
function updateOne(player: GlickoState, opponent: GlickoState, s: number, tau: number): GlickoState {
|
|
119
|
+
const mu = (player.rating - ANCHOR) / SCALE;
|
|
120
|
+
const phi = player.rd / SCALE;
|
|
121
|
+
const sigma = player.vol;
|
|
122
|
+
const muJ = (opponent.rating - ANCHOR) / SCALE;
|
|
123
|
+
const phiJ = opponent.rd / SCALE;
|
|
124
|
+
|
|
125
|
+
const gJ = g(phiJ);
|
|
126
|
+
const e = expectedScore(mu, muJ, phiJ);
|
|
127
|
+
const v = 1 / (gJ * gJ * e * (1 - e));
|
|
128
|
+
const delta = v * gJ * (s - e);
|
|
129
|
+
|
|
130
|
+
const sigmaPrime = newVolatility(sigma, phi, v, delta, tau);
|
|
131
|
+
|
|
132
|
+
const phiStar = Math.sqrt(phi * phi + sigmaPrime * sigmaPrime);
|
|
133
|
+
const phiPrime = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v);
|
|
134
|
+
const muPrime = mu + phiPrime * phiPrime * gJ * (s - e);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
rating: SCALE * muPrime + ANCHOR,
|
|
138
|
+
rd: SCALE * phiPrime,
|
|
139
|
+
vol: sigmaPrime,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Per-pair score from placements: lower place is better. 1 for a win, 0.5 for a tie, 0 for a loss. */
|
|
144
|
+
function scoreOf(myRank: number, theirRank: number): number {
|
|
145
|
+
if (myRank < theirRank) return 1;
|
|
146
|
+
if (myRank === theirRank) return 0.5;
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const glicko: RatingAlgorithm<GlickoState, GlickoParams> = {
|
|
151
|
+
id: "glicko",
|
|
152
|
+
params: GlickoParams,
|
|
153
|
+
state: GlickoState,
|
|
154
|
+
minPlayers: 2,
|
|
155
|
+
maxPlayers: 2,
|
|
156
|
+
supportsTeams: false,
|
|
157
|
+
initial(params) {
|
|
158
|
+
return { rating: params.initialRating, rd: params.initialRd, vol: params.initialVol };
|
|
159
|
+
},
|
|
160
|
+
update(params, entries, outcome) {
|
|
161
|
+
const [a, b] = entries as readonly [RatingEntry<GlickoState>, RatingEntry<GlickoState>];
|
|
162
|
+
const rankA = outcome.ranks[a.playerId];
|
|
163
|
+
const rankB = outcome.ranks[b.playerId];
|
|
164
|
+
if (rankA === undefined || rankB === undefined) {
|
|
165
|
+
throw new InternalError({ detail: "glicko update received an outcome missing a player's rank." });
|
|
166
|
+
}
|
|
167
|
+
const sA = scoreOf(rankA, rankB);
|
|
168
|
+
const sB = scoreOf(rankB, rankA);
|
|
169
|
+
return {
|
|
170
|
+
[a.playerId]: updateOne(a.state, b.state, sA, params.tau),
|
|
171
|
+
[b.playerId]: updateOne(b.state, a.state, sB, params.tau),
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
// Conservative skill: rating minus two deviations. A wide RD (an unproven player) is discounted,
|
|
175
|
+
// so matchmaking and leaderboards rank on what we are confident the player is at least worth.
|
|
176
|
+
skill(_params, state) {
|
|
177
|
+
return state.rating - 2 * state.rd;
|
|
178
|
+
},
|
|
179
|
+
};
|