@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { RatingAlgorithm } from "../algorithm/algorithm";
|
|
6
|
+
import { algorithmBounds, registeredAlgorithmIds, resolveAlgorithm } from "../algorithm/registry";
|
|
7
|
+
import {
|
|
8
|
+
RatingInvalidParamsError,
|
|
9
|
+
RatingUnknownAlgorithmError,
|
|
10
|
+
RatingUnsupportedPlayerCountError,
|
|
11
|
+
} from "../error/errors";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The rating capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field is
|
|
15
|
+
* `.describe()`d: the descriptions feed the self-documenting CLI (CLAUDE.md §Config).
|
|
16
|
+
*
|
|
17
|
+
* A game names a rating **algorithm** by id (`elo`, `glicko`, `trueskill`, or one an adopter registered),
|
|
18
|
+
* carries that algorithm's own `algoParams` tuning, and points at a named **pool** it reads and writes.
|
|
19
|
+
* The tracker holds two distinct numbers per player per pool — a skill rating (MMR, the matchmaking
|
|
20
|
+
* input) and a monotonic experience total (XP, the visible progression). This config is validated at
|
|
21
|
+
* assembly by {@link validateRatingGames}, the same way multiplayer's `validateGames` rejects a bad game.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A game key / pool name is a URL path segment, so it is kebab-case and lowercase. */
|
|
25
|
+
const KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
|
+
|
|
27
|
+
export const RatingXpAward = z
|
|
28
|
+
.object({
|
|
29
|
+
win: z.number().nonnegative().describe("Experience the winner is awarded."),
|
|
30
|
+
draw: z.number().nonnegative().describe("Experience each player is awarded on a draw."),
|
|
31
|
+
loss: z
|
|
32
|
+
.number()
|
|
33
|
+
.nonnegative()
|
|
34
|
+
.describe("Experience a non-winner is awarded — often 0, but a participation point is fine."),
|
|
35
|
+
})
|
|
36
|
+
.describe("Experience awarded per outcome, folded into each player's monotonic XP total.");
|
|
37
|
+
export type RatingXpAward = z.output<typeof RatingXpAward>;
|
|
38
|
+
|
|
39
|
+
export const RatingLevel = z
|
|
40
|
+
.object({
|
|
41
|
+
key: z.string().min(1).describe("The level's stable id — a rank label like `bronze` or `veteran`."),
|
|
42
|
+
from: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.nonnegative()
|
|
46
|
+
.describe("The XP total at which this level begins. Levels list worst to best."),
|
|
47
|
+
})
|
|
48
|
+
.describe("One rung of the XP level ladder — the label a player earns once their XP total reaches `from`.");
|
|
49
|
+
export type RatingLevel = z.output<typeof RatingLevel>;
|
|
50
|
+
|
|
51
|
+
export const RatingGame = z
|
|
52
|
+
.object({
|
|
53
|
+
key: z
|
|
54
|
+
.string()
|
|
55
|
+
.regex(KEY_PATTERN, "A game key is lowercase, digits, and dashes — it is a URL path segment.")
|
|
56
|
+
.describe(
|
|
57
|
+
"The game's stable id, unique across the app. It is a URL path segment and the outcome's game reference.",
|
|
58
|
+
),
|
|
59
|
+
algorithm: z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.describe(
|
|
63
|
+
"Which rating algorithm rates this game: a built-in (`elo` — 1v1, transparent; `glicko` — Glicko-2, 1v1 with uncertainty; `trueskill` — any format, teams) or an id registered with `registerRatingAlgorithm`.",
|
|
64
|
+
),
|
|
65
|
+
algoParams: z
|
|
66
|
+
.unknown()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe(
|
|
69
|
+
"The chosen algorithm's tuning block (K-factor, volatility constraint, prior μ/σ). Validated at assembly by the algorithm; omit to take its defaults.",
|
|
70
|
+
),
|
|
71
|
+
players: z
|
|
72
|
+
.number()
|
|
73
|
+
.int()
|
|
74
|
+
.min(2)
|
|
75
|
+
.default(2)
|
|
76
|
+
.describe(
|
|
77
|
+
"How many players a rated result of this game has (default 2). The chosen algorithm constrains the range — `elo`/`glicko` are 1v1 only, `trueskill` is any count.",
|
|
78
|
+
),
|
|
79
|
+
teams: z
|
|
80
|
+
.boolean()
|
|
81
|
+
.default(false)
|
|
82
|
+
.describe(
|
|
83
|
+
"Whether a result carries a team grouping, so the rating pools each team's skill. Only `trueskill` supports teams; wiring it to another algorithm fails at assembly.",
|
|
84
|
+
),
|
|
85
|
+
pool: z
|
|
86
|
+
.string()
|
|
87
|
+
.regex(KEY_PATTERN, "A pool name is lowercase, digits, and dashes.")
|
|
88
|
+
.optional()
|
|
89
|
+
.describe(
|
|
90
|
+
"The rating pool this game reads and writes. Defaults to the game key (a rating per game). Set a shared name (e.g. `global`) to pool ratings across several games or modes.",
|
|
91
|
+
),
|
|
92
|
+
sharedRoomCounts: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.default(false)
|
|
95
|
+
.describe(
|
|
96
|
+
"Whether games played in a shared room with a friend count toward the ranked ladder (XP / rank). Off by default, so friends cannot farm each other to climb. Skill rating (MMR) always updates from a real result regardless of this flag.",
|
|
97
|
+
),
|
|
98
|
+
hideSkill: z
|
|
99
|
+
.boolean()
|
|
100
|
+
.default(false)
|
|
101
|
+
.describe(
|
|
102
|
+
"Whether the skill rating (MMR) is hidden from players. When on, a player-facing read returns XP, rank, and games but not the raw skill number; matchmaking still uses it internally.",
|
|
103
|
+
),
|
|
104
|
+
xp: RatingXpAward.optional().describe(
|
|
105
|
+
"Experience awarded per outcome. Omit for a game that grants no XP (rating only).",
|
|
106
|
+
),
|
|
107
|
+
levels: z
|
|
108
|
+
.array(RatingLevel)
|
|
109
|
+
.optional()
|
|
110
|
+
.describe("An optional XP level ladder, listed worst to best, deriving a rank/level from the XP total."),
|
|
111
|
+
})
|
|
112
|
+
.describe("One rated game: its algorithm, roster, pool, and experience awards.");
|
|
113
|
+
export type RatingGame = z.output<typeof RatingGame>;
|
|
114
|
+
|
|
115
|
+
export const RatingConfig = z
|
|
116
|
+
.object({
|
|
117
|
+
games: z
|
|
118
|
+
.array(RatingGame)
|
|
119
|
+
.min(1, "A rating capability needs at least one game — configure at least one.")
|
|
120
|
+
.describe("The rated games. Each names an algorithm, a pool, and its experience awards."),
|
|
121
|
+
serverAuthoritative: z
|
|
122
|
+
.boolean()
|
|
123
|
+
.default(true)
|
|
124
|
+
.describe(
|
|
125
|
+
"When true (default), recording an outcome requires the record scope — only a trusted server may write ratings. A client cannot report that it won.",
|
|
126
|
+
),
|
|
127
|
+
recordScope: z
|
|
128
|
+
.string()
|
|
129
|
+
.min(1)
|
|
130
|
+
.default("rating:record")
|
|
131
|
+
.describe("The scope a caller must hold to record an outcome when `serverAuthoritative` is on."),
|
|
132
|
+
})
|
|
133
|
+
.describe("The rating capability's configuration — its games and how outcomes are authorized.")
|
|
134
|
+
.check((ctx) => {
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
for (const game of ctx.value.games) {
|
|
137
|
+
if (seen.has(game.key)) {
|
|
138
|
+
ctx.issues.push({
|
|
139
|
+
code: "custom",
|
|
140
|
+
input: game.key,
|
|
141
|
+
path: ["games"],
|
|
142
|
+
message: `Duplicate game key "${game.key}" — each game key must be unique.`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
seen.add(game.key);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
export type RatingConfig = z.output<typeof RatingConfig>;
|
|
149
|
+
export type RatingConfigInput = z.input<typeof RatingConfig>;
|
|
150
|
+
|
|
151
|
+
/** A game whose algorithm is resolved and whose params are parsed — the runtime-ready form. */
|
|
152
|
+
export interface ResolvedRatingGame {
|
|
153
|
+
/** The game's config (with `pool` defaulted to the key). */
|
|
154
|
+
game: RatingGame & { pool: string };
|
|
155
|
+
/** The resolved algorithm instance. */
|
|
156
|
+
algorithm: RatingAlgorithm;
|
|
157
|
+
/** The parsed, defaulted algorithm params. */
|
|
158
|
+
params: unknown;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Validate every game against the algorithm registry — the assembly-time gate, mirroring multiplayer's
|
|
163
|
+
* `validateGames`. An unknown algorithm, a roster outside the algorithm's supported player count, a team
|
|
164
|
+
* format on an algorithm that cannot rate teams, or invalid `algoParams` all fail here, on deploy, not on
|
|
165
|
+
* the first recorded game. Requires the built-ins (or an adopter's algorithms) to be registered first.
|
|
166
|
+
*/
|
|
167
|
+
export function validateRatingGames(config: RatingConfig): ResolvedRatingGame[] {
|
|
168
|
+
return config.games.map((game) => {
|
|
169
|
+
const algorithm = resolveAlgorithm(game.algorithm);
|
|
170
|
+
if (!algorithm) {
|
|
171
|
+
throw new RatingUnknownAlgorithmError({
|
|
172
|
+
message: `Game "${game.key}" uses unknown algorithm "${game.algorithm}".`,
|
|
173
|
+
action: `Use one of: ${registeredAlgorithmIds().join(", ") || "(none registered)"}, or register one with registerRatingAlgorithm().`,
|
|
174
|
+
detail: `No rating algorithm registered for id "${game.algorithm}".`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
assertPlayerCount(game, algorithm);
|
|
178
|
+
if (game.teams && !algorithm.supportsTeams) {
|
|
179
|
+
throw new RatingUnsupportedPlayerCountError({
|
|
180
|
+
message: `Game "${game.key}" is a team format, but "${algorithm.id}" cannot rate teams.`,
|
|
181
|
+
action: "Use `trueskill` for team games.",
|
|
182
|
+
detail: `Algorithm "${algorithm.id}" has supportsTeams=false.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const params = parseParams(game, algorithm);
|
|
186
|
+
return { game: { ...game, pool: game.pool ?? game.key }, algorithm, params };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The roster size must fall within the algorithm's supported range. */
|
|
191
|
+
function assertPlayerCount(game: RatingGame, algorithm: RatingAlgorithm): void {
|
|
192
|
+
const { min, max } = algorithmBounds(algorithm);
|
|
193
|
+
if (game.players < min || game.players > max) {
|
|
194
|
+
const upper = max === Number.POSITIVE_INFINITY ? "" : `–${max}`;
|
|
195
|
+
throw new RatingUnsupportedPlayerCountError({
|
|
196
|
+
message: `Game "${game.key}" sets ${game.players} players, but "${algorithm.id}" supports ${min}${upper}.`,
|
|
197
|
+
action: "Use `trueskill` for N-player or team games; `elo` and `glicko` are 1v1 only.",
|
|
198
|
+
detail: `Algorithm "${algorithm.id}" player bounds [${min}, ${max}] exclude ${game.players}.`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Parse the `algoParams` block against the algorithm's schema, surfacing a Zod failure as a config error. */
|
|
204
|
+
function parseParams(game: RatingGame, algorithm: RatingAlgorithm): unknown {
|
|
205
|
+
const parsed = algorithm.params.safeParse(game.algoParams ?? {});
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
throw new RatingInvalidParamsError({
|
|
208
|
+
message: `Game "${game.key}" has invalid algoParams for "${algorithm.id}".`,
|
|
209
|
+
action: "Fix the game's `algoParams` block in pithy.config.ts.",
|
|
210
|
+
detail: `algoParams failed validation: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return parsed.data;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The resolved game for a key, or undefined. */
|
|
217
|
+
export function resolveGame(games: readonly ResolvedRatingGame[], key: string): ResolvedRatingGame | undefined {
|
|
218
|
+
return games.find((g) => g.game.key === key);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Every distinct pool name the configured games read or write. */
|
|
222
|
+
export function configuredPools(games: readonly ResolvedRatingGame[]): string[] {
|
|
223
|
+
return [...new Set(games.map((g) => g.game.pool))];
|
|
224
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One player's standing in one rating pool — the row in `pithy_rating_ratings`. It carries the tracker's
|
|
9
|
+
* **two distinct numbers**: `skill` (the MMR the matchmaker buckets on, up and down, opponent-weighted)
|
|
10
|
+
* and `xp` (the monotonic experience total that only ever rises). The algorithm-specific `state` blob is
|
|
11
|
+
* the source of `skill`; it is stored opaque here and validated against the resolving algorithm's own
|
|
12
|
+
* `state` schema on read (defense in depth — this table cannot know which algorithm owns a pool).
|
|
13
|
+
*/
|
|
14
|
+
export const RatingRecord = z
|
|
15
|
+
.object({
|
|
16
|
+
id: z.number().int().describe("Autoincrement PK. Internal only."),
|
|
17
|
+
pool: z.string().describe("The rating pool this row belongs to. A pool is rated by a single algorithm."),
|
|
18
|
+
userId: z
|
|
19
|
+
.string()
|
|
20
|
+
.describe("The player's authenticated user id (`pithy_auth_users.id`), never a client-supplied id."),
|
|
21
|
+
algorithm: z
|
|
22
|
+
.string()
|
|
23
|
+
.describe("The algorithm id that produced `state` — recorded so a read validates the blob correctly."),
|
|
24
|
+
state: sqliteJson(z.unknown()).describe(
|
|
25
|
+
"The algorithm's per-player rating state (Elo `{rating}`, Glicko-2 `{rating,rd,vol}`, TrueSkill `{mu,sigma}`), stored as JSON and re-validated by the algorithm on read.",
|
|
26
|
+
),
|
|
27
|
+
skill: z
|
|
28
|
+
.number()
|
|
29
|
+
.describe(
|
|
30
|
+
"The conservative comparable skill number (`algorithm.skill(state)`), denormalized so matchmaking buckets and reads never re-derive it.",
|
|
31
|
+
),
|
|
32
|
+
xp: z.number().describe("The player's monotonic experience total in this pool — only ever rises."),
|
|
33
|
+
games: z.number().int().describe("How many rated games this player has completed in this pool."),
|
|
34
|
+
updatedAt: SQLiteDate.describe("When this row last changed."),
|
|
35
|
+
})
|
|
36
|
+
.describe("One player's rating and experience in one pool.");
|
|
37
|
+
|
|
38
|
+
export type RatingRecord = z.output<typeof RatingRecord>;
|
|
39
|
+
export type RatingRecordRow = z.input<typeof RatingRecord>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
5
|
+
import { RatingRecord } from "./rating";
|
|
6
|
+
import { RATING_RATINGS_TABLE, type RatingDatabase } from "./tables";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The rating store — the only place that reads and writes `pithy_rating_ratings`. Reads decode with
|
|
10
|
+
* `RatingRecord.parse` (SQLite row → app shape); writes encode with `RatingRecord.encode` then upsert on
|
|
11
|
+
* the unique `(pool, userId)` pair, so recording the same roster twice updates in place rather than
|
|
12
|
+
* duplicating. The round-trip rule (CLAUDE.md §Data layer) is honored on every path.
|
|
13
|
+
*/
|
|
14
|
+
export interface RatingStore {
|
|
15
|
+
/** A player's standing in a pool, or undefined if they have no rated games there yet. */
|
|
16
|
+
get(pool: string, userId: string): Promise<RatingRecord | undefined>;
|
|
17
|
+
/** Every existing standing for a set of players in a pool (a game's roster). Missing players are absent. */
|
|
18
|
+
getMany(pool: string, userIds: readonly string[]): Promise<RatingRecord[]>;
|
|
19
|
+
/** Insert or update a player's standing, keyed on `(pool, userId)`. */
|
|
20
|
+
upsert(record: RatingRecord): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ratingStore(db: RatingDatabase): RatingStore {
|
|
24
|
+
return {
|
|
25
|
+
async get(pool, userId) {
|
|
26
|
+
const row = await db
|
|
27
|
+
.selectFrom(RATING_RATINGS_TABLE)
|
|
28
|
+
.selectAll()
|
|
29
|
+
.where("pool", "=", pool)
|
|
30
|
+
.where("userId", "=", userId)
|
|
31
|
+
.executeTakeFirst();
|
|
32
|
+
return row ? RatingRecord.parse(row) : undefined;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async getMany(pool, userIds) {
|
|
36
|
+
if (userIds.length === 0) return [];
|
|
37
|
+
// A roster is as big as the game says it is: `players` has a minimum of two and no maximum, and
|
|
38
|
+
// the docs promise any count. So the roster is chunked against D1's cap rather than assumed to
|
|
39
|
+
// fit — one parameter per player, plus the pool. Unchunked, a 120-player game failed every
|
|
40
|
+
// `recordResult`, and no two-player test could have said so (#250).
|
|
41
|
+
const records: RatingRecord[] = [];
|
|
42
|
+
for (const chunk of chunkByBoundParameters(userIds, 1)) {
|
|
43
|
+
const rows = await db
|
|
44
|
+
.selectFrom(RATING_RATINGS_TABLE)
|
|
45
|
+
.selectAll()
|
|
46
|
+
.where("pool", "=", pool)
|
|
47
|
+
.where("userId", "in", chunk)
|
|
48
|
+
.execute();
|
|
49
|
+
for (const row of rows) records.push(RatingRecord.parse(row));
|
|
50
|
+
}
|
|
51
|
+
return records;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async upsert(record) {
|
|
55
|
+
const row = RatingRecord.encode(record) as Record<string, unknown>;
|
|
56
|
+
delete row.id;
|
|
57
|
+
await db
|
|
58
|
+
.insertInto(RATING_RATINGS_TABLE)
|
|
59
|
+
.values(row as never)
|
|
60
|
+
.onConflict((oc) =>
|
|
61
|
+
oc.columns(["pool", "userId"]).doUpdateSet({
|
|
62
|
+
algorithm: record.algorithm,
|
|
63
|
+
state: RatingRecord.shape.state.encode(record.state),
|
|
64
|
+
skill: record.skill,
|
|
65
|
+
xp: record.xp,
|
|
66
|
+
games: record.games,
|
|
67
|
+
updatedAt: RatingRecord.shape.updatedAt.encode(record.updatedAt),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
.execute();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
import { RatingRecord } from "./rating";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The rating capability's tables. One table today — `pithy_rating_ratings` — holding both numbers per
|
|
12
|
+
* player per pool. Table names are camelCase constants; core's `createDatabase` installs the mandatory
|
|
13
|
+
* `CamelCasePlugin`, so query code never types the snake_case `pithy_rating_*` columns.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const RATING_RATINGS_TABLE = "pithyRatingRatings";
|
|
17
|
+
|
|
18
|
+
export function ratingTables(): Record<string, z.ZodObject> {
|
|
19
|
+
return { [RATING_RATINGS_TABLE]: RatingRecord };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type RatingTables = { [RATING_RATINGS_TABLE]: typeof RatingRecord };
|
|
23
|
+
export type RatingDatabase = Kysely<DatabaseSchema<RatingTables>>;
|
|
24
|
+
|
|
25
|
+
export function ratingDatabase(d1: D1Database): RatingDatabase {
|
|
26
|
+
return createDatabase(d1, { [RATING_RATINGS_TABLE]: RatingRecord }) as unknown as RatingDatabase;
|
|
27
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@pithy-sh/rating` throw sugar. The `rating/*` codes live in core's closed `KitErrorPayload` union
|
|
9
|
+
* (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
|
|
10
|
+
* package-local vehicles that set one of those members — the same pattern as `@pithy-sh/leaderboard` and
|
|
11
|
+
* `@pithy-sh/multiplayer`. Runtime code in this package throws one of these, never a plain `new Error`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
|
|
15
|
+
interface RatingErrorArgs {
|
|
16
|
+
/** Override the public, safe-to-expose message. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** A remediation hint (CLI action line). */
|
|
19
|
+
action?: string;
|
|
20
|
+
/** Internal context for logs + audit. Never serialized to clients. */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
24
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
25
|
+
*/
|
|
26
|
+
params?: MessageParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A game wires a rating algorithm id that no one registered. Thrown at assembly. */
|
|
30
|
+
export class RatingUnknownAlgorithmError extends PithyError {
|
|
31
|
+
constructor(args: RatingErrorArgs = {}, options?: { cause?: unknown }) {
|
|
32
|
+
super(
|
|
33
|
+
{
|
|
34
|
+
code: "rating/unknown_algorithm",
|
|
35
|
+
status: 400,
|
|
36
|
+
message: args.message ?? "That rating algorithm does not exist.",
|
|
37
|
+
action:
|
|
38
|
+
args.action ?? "Use a built-in (elo, glicko, trueskill) or register one with registerRatingAlgorithm().",
|
|
39
|
+
detail: args.detail,
|
|
40
|
+
params: args.params,
|
|
41
|
+
},
|
|
42
|
+
options,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A game's roster or team format falls outside the chosen algorithm's support. Thrown at assembly. */
|
|
48
|
+
export class RatingUnsupportedPlayerCountError extends PithyError {
|
|
49
|
+
constructor(args: RatingErrorArgs = {}, options?: { cause?: unknown }) {
|
|
50
|
+
super(
|
|
51
|
+
{
|
|
52
|
+
code: "rating/unsupported_player_count",
|
|
53
|
+
status: 400,
|
|
54
|
+
message: args.message ?? "This algorithm cannot rate that game's shape.",
|
|
55
|
+
action: args.action ?? "Use trueskill for N-player or team games; elo and glicko are 1v1 only.",
|
|
56
|
+
detail: args.detail,
|
|
57
|
+
params: args.params,
|
|
58
|
+
},
|
|
59
|
+
options,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A game's `algoParams` block failed the algorithm's own validation. Thrown at assembly. */
|
|
65
|
+
export class RatingInvalidParamsError extends PithyError {
|
|
66
|
+
constructor(args: RatingErrorArgs = {}, options?: { cause?: unknown }) {
|
|
67
|
+
super(
|
|
68
|
+
{
|
|
69
|
+
code: "rating/invalid_params",
|
|
70
|
+
status: 400,
|
|
71
|
+
message: args.message ?? "The algorithm's tuning is invalid.",
|
|
72
|
+
action: args.action ?? "Fix the game's `algoParams` block in pithy.config.ts.",
|
|
73
|
+
detail: args.detail,
|
|
74
|
+
params: args.params,
|
|
75
|
+
},
|
|
76
|
+
options,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A request referenced a game key that is not configured. */
|
|
82
|
+
export class RatingGameNotFoundError extends PithyError {
|
|
83
|
+
constructor(args: RatingErrorArgs = {}, options?: { cause?: unknown }) {
|
|
84
|
+
super(
|
|
85
|
+
{
|
|
86
|
+
code: "rating/game_not_found",
|
|
87
|
+
status: 404,
|
|
88
|
+
message: args.message ?? "That game does not exist.",
|
|
89
|
+
action: args.action ?? "Check the game key against the `games` list in pithy.config.ts.",
|
|
90
|
+
detail: args.detail,
|
|
91
|
+
params: args.params,
|
|
92
|
+
},
|
|
93
|
+
options,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A request referenced a rating pool no game configures. */
|
|
99
|
+
export class RatingPoolNotFoundError extends PithyError {
|
|
100
|
+
constructor(args: RatingErrorArgs = {}, options?: { cause?: unknown }) {
|
|
101
|
+
super(
|
|
102
|
+
{
|
|
103
|
+
code: "rating/pool_not_found",
|
|
104
|
+
status: 404,
|
|
105
|
+
message: args.message ?? "That rating pool does not exist.",
|
|
106
|
+
action: args.action ?? "Reference a pool a configured game reads or writes.",
|
|
107
|
+
detail: args.detail,
|
|
108
|
+
params: args.params,
|
|
109
|
+
},
|
|
110
|
+
options,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The authenticated caller lacks the server-authoritative record scope. */
|
|
116
|
+
export class RatingRecordForbiddenError extends PithyError {
|
|
117
|
+
constructor(args: RatingErrorArgs = {}, options?: { cause?: unknown }) {
|
|
118
|
+
super(
|
|
119
|
+
{
|
|
120
|
+
code: "rating/record_forbidden",
|
|
121
|
+
status: 403,
|
|
122
|
+
message: args.message ?? "You may not record game outcomes.",
|
|
123
|
+
action: args.action ?? "Record outcomes from a trusted server holding the record scope.",
|
|
124
|
+
detail: args.detail,
|
|
125
|
+
params: args.params,
|
|
126
|
+
},
|
|
127
|
+
options,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { RatingLevel, RatingXpAward } from "../config/config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Experience is the visible progression — a monotonic total that only ever rises, distinct from the
|
|
8
|
+
* skill rating that moves both ways. This module owns the two pure rules: how an award folds into the
|
|
9
|
+
* total, and how a total classifies into a rank/level.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A player's outcome in a single game, for the purpose of an XP award. */
|
|
13
|
+
export type XpOutcome = "win" | "draw" | "loss";
|
|
14
|
+
|
|
15
|
+
/** Fold an award into the running total. XP never decreases: a negative award is clamped to zero. */
|
|
16
|
+
export function awardXp(current: number, award: number): number {
|
|
17
|
+
return current + Math.max(0, award);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The XP a given outcome is worth under a game's award table. */
|
|
21
|
+
export function xpFor(award: RatingXpAward, outcome: XpOutcome): number {
|
|
22
|
+
return award[outcome];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The rank/level a total earns, from a worst-to-best ladder — the best rung whose `from` the total has
|
|
27
|
+
* reached, or `null` when it has not reached the first rung. The ladder is sorted defensively so order in
|
|
28
|
+
* config never changes the answer.
|
|
29
|
+
*/
|
|
30
|
+
export function classifyLevel(levels: readonly RatingLevel[], xp: number): string | null {
|
|
31
|
+
let current: string | null = null;
|
|
32
|
+
for (const level of [...levels].sort((a, b) => a.from - b.from)) {
|
|
33
|
+
if (xp >= level.from) current = level.key;
|
|
34
|
+
}
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
import { RatingRecordForbiddenError } from "../error/errors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Route guards over the `AuthContext` seam. Copied — not imported from `@pithy-sh/auth` — so the rating
|
|
11
|
+
* capability keeps `dependsOn` empty: without auth installed, `c.var.auth` is null and every guarded
|
|
12
|
+
* route is denied rather than open (the leaderboard pattern).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Deny any request without a resolved identity. The first handler on every rating route. */
|
|
16
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
17
|
+
return async (c, next) => {
|
|
18
|
+
if (!c.var.auth) {
|
|
19
|
+
throw new UnauthorizedError({
|
|
20
|
+
message: "Authentication required.",
|
|
21
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
await next();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Gate outcome recording behind the record scope when the capability is server-authoritative. A client
|
|
30
|
+
* cannot report that it won; only a trusted server holding the scope may write ratings.
|
|
31
|
+
*/
|
|
32
|
+
export function requireRecordScope(serverAuthoritative: boolean, scope: string): MiddlewareHandler<PithyHonoEnv> {
|
|
33
|
+
return async (c, next) => {
|
|
34
|
+
if (serverAuthoritative && !c.var.auth?.scopes.includes(scope)) {
|
|
35
|
+
throw new RatingRecordForbiddenError({ detail: `Recording an outcome requires the "${scope}" scope.` });
|
|
36
|
+
}
|
|
37
|
+
await next();
|
|
38
|
+
};
|
|
39
|
+
}
|