@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.
@@ -0,0 +1,137 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
7
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
9
+ import type { Context, Hono } from "hono";
10
+ import type { RatingConfig, ResolvedRatingGame } from "../config/config";
11
+ import { resolveGame } from "../config/config";
12
+ import { ratingStore } from "../data/store";
13
+ import { ratingDatabase } from "../data/tables";
14
+ import { RatingGameNotFoundError } from "../error/errors";
15
+ import { classifyLevel } from "../experience/xp";
16
+ import { recordOutcome } from "../record/record";
17
+ import { requireAuth, requireRecordScope } from "./guard";
18
+ import { RatingGameParams, RatingPlayerParams, RecordOutcomeBody } from "./schemas";
19
+
20
+ /**
21
+ * The rating capability's HTTP surface. Three routes, every one `requireAuth()`-gated — there is no
22
+ * public surface:
23
+ *
24
+ * | Method | Path | Strategy | Validates |
25
+ * |--------|---------------------------------------|---------------------|-----------|
26
+ * | POST | /rating/games/:game/outcomes | bearer \| session + record scope | param `RatingGameParams`, json `RecordOutcomeBody` |
27
+ * | GET | /rating/games/:game/me | bearer \| session | param `RatingGameParams` |
28
+ * | GET | /rating/games/:game/players/:userId | bearer \| session | param `RatingPlayerParams` |
29
+ *
30
+ * Validators run **after** the guards, so an unauthenticated or unscoped caller is still denied 401/403
31
+ * whatever it sends. A param schema bounds the segment's shape only — resolving the key against the
32
+ * configured games stays in the handler, and an unknown game is still a `rating/game_not_found` 404.
33
+ *
34
+ * Recording is server-authoritative by default: a client cannot report a win. Reads honor the game's
35
+ * `hideSkill` flag — a hidden rating returns XP, rank, and games but a `null` skill number.
36
+ */
37
+ export interface RatingRoutesOptions {
38
+ /** The resolved, validated games. */
39
+ games: readonly ResolvedRatingGame[];
40
+ /** The resolved config (for auth mode + scope). */
41
+ config: RatingConfig;
42
+ /** Mount the routes somewhere other than `/rating`. */
43
+ basePath?: string;
44
+ }
45
+
46
+ /** A player-facing view of a standing. `skill` is null when the game hides it. */
47
+ interface RatingView {
48
+ pool: string;
49
+ userId: string;
50
+ skill: number | null;
51
+ xp: number;
52
+ level: string | null;
53
+ games: number;
54
+ provisional: boolean;
55
+ }
56
+
57
+ export function registerRatingRoutes(options: RatingRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
58
+ const base = options.basePath ?? "/rating";
59
+ const { games, config } = options;
60
+
61
+ return (app) => {
62
+ app.post(
63
+ `${base}/games/:game/outcomes`,
64
+ requireAuth(),
65
+ requireRecordScope(config.serverAuthoritative, config.recordScope),
66
+ zValidator("param", RatingGameParams, validationHook),
67
+ zValidator("json", RecordOutcomeBody, validationHook),
68
+ async (c) => {
69
+ const resolved = game(games, c.req.valid("param").game);
70
+ const body = c.req.valid("json");
71
+ const recorded = await recordOutcome(ratingStore(ratingDatabase(dbOf(c))), resolved, {
72
+ ranks: body.ranks,
73
+ teams: body.teams,
74
+ sharedRoom: body.sharedRoom,
75
+ at: new Date(),
76
+ });
77
+ return c.json({ game: resolved.game.key, pool: resolved.game.pool, players: recorded }, 201);
78
+ },
79
+ );
80
+
81
+ app.get(
82
+ `${base}/games/:game/me`,
83
+ requireAuth(),
84
+ zValidator("param", RatingGameParams, validationHook),
85
+ async (c) => {
86
+ const resolved = game(games, c.req.valid("param").game);
87
+ return c.json(await view(c, resolved, userId(c)));
88
+ },
89
+ );
90
+
91
+ app.get(
92
+ `${base}/games/:game/players/:userId`,
93
+ requireAuth(),
94
+ zValidator("param", RatingPlayerParams, validationHook),
95
+ async (c) => {
96
+ const params = c.req.valid("param");
97
+ const resolved = game(games, params.game);
98
+ return c.json(await view(c, resolved, params.userId));
99
+ },
100
+ );
101
+ };
102
+ }
103
+
104
+ /** Build a player's view for a game's pool, applying the game's `hideSkill` flag. Provisional if unrated. */
105
+ async function view(c: Context<PithyHonoEnv>, resolved: ResolvedRatingGame, player: string): Promise<RatingView> {
106
+ const { game: cfg, algorithm, params } = resolved;
107
+ const record = await ratingStore(ratingDatabase(dbOf(c))).get(cfg.pool, player);
108
+ const skill = record ? record.skill : algorithm.skill(params, algorithm.initial(params));
109
+ const xp = record?.xp ?? 0;
110
+ return {
111
+ pool: cfg.pool,
112
+ userId: player,
113
+ skill: cfg.hideSkill ? null : skill,
114
+ xp,
115
+ level: cfg.levels ? classifyLevel(cfg.levels, xp) : null,
116
+ games: record?.games ?? 0,
117
+ provisional: record === undefined,
118
+ };
119
+ }
120
+
121
+ function game(games: readonly ResolvedRatingGame[], key: string): ResolvedRatingGame {
122
+ const resolved = resolveGame(games, key);
123
+ if (!resolved) throw new RatingGameNotFoundError({ detail: `No game "${key}" is configured.` });
124
+ return resolved;
125
+ }
126
+
127
+ function userId(c: Context<PithyHonoEnv>): string {
128
+ const auth = c.var.auth;
129
+ if (!auth) throw new InternalError({ detail: "requireAuth() must run before a handler reads the user id." });
130
+ return auth.userId;
131
+ }
132
+
133
+ function dbOf(c: Context<PithyHonoEnv>): D1Database {
134
+ const db = c.env.DB as D1Database | undefined;
135
+ if (!db) throw new InternalError({ detail: "The rating routes require a `DB` D1 binding." });
136
+ return db;
137
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * HTTP-boundary shapes for the rating routes. Declared on the route line with
8
+ * `zValidator(target, Schema, validationHook)`, so a malformed request becomes a
9
+ * `validation/invalid_input` 400 before any handler runs, never an unhandled throw.
10
+ *
11
+ * The param schemas are **shape** checks, not existence checks: they bound what may reach a store or a
12
+ * config lookup. Resolving a game key against the configured games stays in the handler, and an unknown
13
+ * game is still a `rating/game_not_found` 404.
14
+ */
15
+
16
+ /**
17
+ * A game key is a URL path segment, so it is lowercase, digits, and dashes — the same shape
18
+ * `RatingGame.key` enforces at config assembly. A key that cannot be configured cannot resolve, so
19
+ * bounding the segment here rejects it a step earlier without narrowing what already works.
20
+ */
21
+ const GAME_KEY = /^[a-z0-9][a-z0-9-]*$/;
22
+
23
+ export const RatingGameParams = z
24
+ .object({
25
+ game: z
26
+ .string()
27
+ .max(64)
28
+ .regex(GAME_KEY, "A game key is lowercase, digits, and dashes — it is a URL path segment.")
29
+ .describe("The configured game the request addresses. Resolved against the games in the handler."),
30
+ })
31
+ .describe("The path params of a game-scoped rating route.");
32
+ export type RatingGameParams = z.output<typeof RatingGameParams>;
33
+
34
+ export const RatingPlayerParams = z
35
+ .object({
36
+ game: z
37
+ .string()
38
+ .max(64)
39
+ .regex(GAME_KEY, "A game key is lowercase, digits, and dashes — it is a URL path segment.")
40
+ .describe("The configured game the request addresses. Resolved against the games in the handler."),
41
+ userId: z
42
+ .string()
43
+ .min(1)
44
+ .max(200)
45
+ .describe("The player whose standing is read. Opaque to this capability — bounded, not parsed."),
46
+ })
47
+ .describe("The path params of a read of one player's standing in a game.");
48
+ export type RatingPlayerParams = z.output<typeof RatingPlayerParams>;
49
+
50
+ export const RecordOutcomeBody = z
51
+ .object({
52
+ ranks: z
53
+ .record(z.string(), z.number().int().min(1))
54
+ .describe("Each player's finishing place, keyed by user id — 1 is the winner, ties share a place."),
55
+ teams: z
56
+ .record(z.string(), z.string())
57
+ .optional()
58
+ .describe("Optional team grouping (player id → team id), for a team format."),
59
+ sharedRoom: z
60
+ .boolean()
61
+ .optional()
62
+ .describe("Whether the game was played in a shared room with a friend. Gates XP/rank, never the skill rating."),
63
+ })
64
+ .describe("The body of a record-outcome request — the result of one rated game.");
65
+ export type RecordOutcomeBody = z.output<typeof RecordOutcomeBody>;
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add rating` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory, its config/options types, the rating-algorithm seam (so an adopter can
7
+ * register their own), the built-in algorithms, and the read/record shapes an app renders. Every other
8
+ * module is imported by deep path (`@pithy-sh/rating/src/...`); this is the documented contract, not a
9
+ * barrel over the package.
10
+ */
11
+
12
+ export type { RatedOutcome, RatingAlgorithm, RatingEntry } from "./algorithm/algorithm";
13
+ export { elo } from "./algorithm/builtins/elo";
14
+ export { glicko } from "./algorithm/builtins/glicko";
15
+ export { trueskill } from "./algorithm/builtins/trueskill";
16
+ export {
17
+ algorithmBounds,
18
+ registeredAlgorithmIds,
19
+ registerRatingAlgorithm,
20
+ resolveAlgorithm,
21
+ } from "./algorithm/registry";
22
+ export {
23
+ isRatingCapability,
24
+ RATING_MIGRATION_ORDER,
25
+ type RatingCapability,
26
+ type RatingOptions,
27
+ rating,
28
+ } from "./capability";
29
+ export {
30
+ configuredPools,
31
+ RatingConfig,
32
+ type RatingConfigInput,
33
+ RatingGame,
34
+ RatingLevel,
35
+ RatingXpAward,
36
+ type ResolvedRatingGame,
37
+ resolveGame,
38
+ validateRatingGames,
39
+ } from "./config/config";
40
+ export { RatingRecord } from "./data/rating";
41
+ export { awardXp, classifyLevel, type XpOutcome, xpFor } from "./experience/xp";
42
+ export { type RecordedPlayer, type RecordOutcomeInput, recordOutcome } from "./record/record";
@@ -0,0 +1,49 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Creates `pithy_rating_ratings` — one row per (pool, player) holding the skill rating state, the
9
+ * denormalized conservative `skill` number, and the monotonic `xp` total. All identifiers are camelCase;
10
+ * the migration runner's `CamelCasePlugin` snake-cases them to `pithy_rating_*` columns.
11
+ *
12
+ * The unique `(pool, userId)` index is the upsert conflict target. The `(pool, skill)` index serves
13
+ * matchmaking's skill-bucketed reads and any leaderboard-style rank scan over the pool.
14
+ */
15
+ export const rating_0001_rating: Migration = {
16
+ up: async (db: Kysely<unknown>) => {
17
+ await db.schema
18
+ .createTable("pithyRatingRatings")
19
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
20
+ .addColumn("pool", "text", (c) => c.notNull())
21
+ .addColumn("userId", "text", (c) => c.notNull())
22
+ .addColumn("algorithm", "text", (c) => c.notNull())
23
+ .addColumn("state", "text", (c) => c.notNull())
24
+ .addColumn("skill", "real", (c) => c.notNull())
25
+ .addColumn("xp", "real", (c) => c.notNull().defaultTo(0))
26
+ .addColumn("games", "integer", (c) => c.notNull().defaultTo(0))
27
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
28
+ .execute();
29
+
30
+ await db.schema
31
+ .createIndex("pithyRatingRatingsPlayerIdx")
32
+ .on("pithyRatingRatings")
33
+ .columns(["pool", "userId"])
34
+ .unique()
35
+ .execute();
36
+
37
+ await db.schema
38
+ .createIndex("pithyRatingRatingsSkillIdx")
39
+ .on("pithyRatingRatings")
40
+ .columns(["pool", "skill"])
41
+ .execute();
42
+ },
43
+
44
+ down: async (db: Kysely<unknown>) => {
45
+ await db.schema.dropIndex("pithyRatingRatingsSkillIdx").execute();
46
+ await db.schema.dropIndex("pithyRatingRatingsPlayerIdx").execute();
47
+ await db.schema.dropTable("pithyRatingRatings").execute();
48
+ },
49
+ };
@@ -0,0 +1,138 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { RatedOutcome, RatingEntry } from "../algorithm/algorithm";
6
+ import type { ResolvedRatingGame } from "../config/config";
7
+ import type { RatingRecord } from "../data/rating";
8
+ import type { RatingStore } from "../data/store";
9
+ import { awardXp, classifyLevel, type XpOutcome, xpFor } from "../experience/xp";
10
+
11
+ /**
12
+ * Recording a game's outcome — the heart of the tracker. It loads each player's current standing in the
13
+ * game's pool (a newcomer starts at the algorithm's `initial`), runs the algorithm's pure `update` to get
14
+ * everyone's new skill state, folds in experience, and upserts each row.
15
+ *
16
+ * Two rules from the issue live here:
17
+ * - **Skill rating (MMR) always updates from a real result** — a game is a game.
18
+ * - **Experience / rank is gated**: a shared-room (friend) game counts toward XP only when the game opts
19
+ * in with `sharedRoomCounts`. By default friends cannot farm each other for XP.
20
+ */
21
+
22
+ /** The outcome to record: a placement per player, an optional team grouping, and provenance. */
23
+ export interface RecordOutcomeInput {
24
+ /** Each player's finishing place (1 = winner; ties share a place). Every roster member must appear. */
25
+ ranks: Record<string, number>;
26
+ /** Optional team grouping (player id → team id), for a team format. */
27
+ teams?: Record<string, string>;
28
+ /** Whether this game was played in a shared room with a friend. Gates XP/rank, never MMR. */
29
+ sharedRoom?: boolean;
30
+ /** When the game finished. */
31
+ at: Date;
32
+ }
33
+
34
+ /** One player's standing after a recorded game. */
35
+ export interface RecordedPlayer {
36
+ /** The player's user id. */
37
+ userId: string;
38
+ /** Their new conservative skill number (the MMR the matchmaker buckets on). */
39
+ skill: number;
40
+ /** Their new monotonic experience total in the pool. */
41
+ xp: number;
42
+ /** Their rank/level from the game's ladder, or null. */
43
+ level: string | null;
44
+ /** Their new algorithm state blob. */
45
+ state: unknown;
46
+ /** How many rated games they have now completed in the pool. */
47
+ games: number;
48
+ }
49
+
50
+ export async function recordOutcome(
51
+ store: RatingStore,
52
+ resolved: ResolvedRatingGame,
53
+ input: RecordOutcomeInput,
54
+ ): Promise<RecordedPlayer[]> {
55
+ const { game, algorithm, params } = resolved;
56
+ const playerIds = Object.keys(input.ranks);
57
+
58
+ if (playerIds.length !== game.players) {
59
+ throw new ValidationError({
60
+ message: `This game expects ${game.players} players, but the outcome names ${playerIds.length}.`,
61
+ action: "Report exactly the game's roster in `ranks`.",
62
+ detail: `Game "${game.key}" players=${game.players}, ranks has ${playerIds.length}.`,
63
+ });
64
+ }
65
+ if (input.teams) {
66
+ for (const id of playerIds) {
67
+ if (!(id in input.teams)) {
68
+ throw new ValidationError({
69
+ message: "Every player must be assigned to a team.",
70
+ action: "Include each player id in `teams`.",
71
+ detail: `Player "${id}" is in ranks but not teams.`,
72
+ });
73
+ }
74
+ }
75
+ }
76
+
77
+ const existing = await store.getMany(game.pool, playerIds);
78
+ const byUser = new Map(existing.map((record) => [record.userId, record]));
79
+
80
+ const entries: RatingEntry[] = playerIds.map((userId) => {
81
+ const record = byUser.get(userId);
82
+ const state = record ? algorithm.state.parse(record.state) : algorithm.initial(params);
83
+ return { playerId: userId, state };
84
+ });
85
+
86
+ const outcome: RatedOutcome = { ranks: input.ranks, teams: input.teams };
87
+ const nextStates = algorithm.update(params, entries, outcome);
88
+
89
+ const countsForXp = !input.sharedRoom || game.sharedRoomCounts;
90
+ const results: RecordedPlayer[] = [];
91
+
92
+ for (const userId of playerIds) {
93
+ const nextState = nextStates[userId];
94
+ if (nextState === undefined) {
95
+ throw new ValidationError({
96
+ message: "The rating algorithm did not return a state for every player.",
97
+ detail: `Algorithm "${algorithm.id}" omitted player "${userId}" from its update.`,
98
+ });
99
+ }
100
+ const prior = byUser.get(userId);
101
+ const priorXp = prior?.xp ?? 0;
102
+ const gained = game.xp && countsForXp ? xpFor(game.xp, xpOutcome(userId, input.ranks)) : 0;
103
+ const xp = awardXp(priorXp, gained);
104
+
105
+ const record: RatingRecord = {
106
+ id: 0,
107
+ pool: game.pool,
108
+ userId,
109
+ algorithm: algorithm.id,
110
+ state: nextState,
111
+ skill: algorithm.skill(params, nextState),
112
+ xp,
113
+ games: (prior?.games ?? 0) + 1,
114
+ updatedAt: input.at,
115
+ };
116
+ await store.upsert(record);
117
+
118
+ results.push({
119
+ userId,
120
+ skill: record.skill,
121
+ xp,
122
+ level: game.levels ? classifyLevel(game.levels, xp) : null,
123
+ state: nextState,
124
+ games: record.games,
125
+ });
126
+ }
127
+
128
+ return results;
129
+ }
130
+
131
+ /** A player's win/draw/loss for XP: sole top place is a win, a shared top place is a draw, else a loss. */
132
+ function xpOutcome(userId: string, ranks: Record<string, number>): XpOutcome {
133
+ const mine = ranks[userId];
134
+ const best = Math.min(...Object.values(ranks));
135
+ if (mine !== best) return "loss";
136
+ const atBest = Object.values(ranks).filter((r) => r === best).length;
137
+ return atBest === 1 ? "win" : "draw";
138
+ }
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { RatingRecord } from "../data/rating";
7
+ import { RATING_RATINGS_TABLE } from "../data/tables";
8
+
9
+ /**
10
+ * Three canonical example players in a `demo` pool rated by Elo — the same cast auth seeds. Ada leads on
11
+ * skill and experience, Alan trails; a shape you can read a `/rating/games/<key>/me` response against
12
+ * immediately after `pithy seed`. Never runs in production.
13
+ */
14
+ const RATING_EXAMPLE_SEED_ORDER = 210;
15
+ const now = () => new Date();
16
+
17
+ export const ratingExampleSeed: SeedSet = defineSeed({
18
+ name: "example",
19
+ order: RATING_EXAMPLE_SEED_ORDER,
20
+ environments: ["dev", "staging"],
21
+ example: true,
22
+ d1: [
23
+ d1SeedGroup("app", RATING_RATINGS_TABLE, RatingRecord, [
24
+ {
25
+ id: 1,
26
+ pool: "demo",
27
+ userId: EXAMPLE_ADA.id,
28
+ algorithm: "elo",
29
+ state: { rating: 1560 },
30
+ skill: 1560,
31
+ xp: 120,
32
+ games: 8,
33
+ updatedAt: now(),
34
+ },
35
+ {
36
+ id: 2,
37
+ pool: "demo",
38
+ userId: EXAMPLE_GRACE.id,
39
+ algorithm: "elo",
40
+ state: { rating: 1500 },
41
+ skill: 1500,
42
+ xp: 90,
43
+ games: 6,
44
+ updatedAt: now(),
45
+ },
46
+ {
47
+ id: 3,
48
+ pool: "demo",
49
+ userId: EXAMPLE_ALAN.id,
50
+ algorithm: "elo",
51
+ state: { rating: 1440 },
52
+ skill: 1440,
53
+ xp: 60,
54
+ games: 5,
55
+ updatedAt: now(),
56
+ },
57
+ ]),
58
+ ],
59
+ });
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/rating knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/rating";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";