From 7c6a0ec9a9148fde84a320357c145b32258089a5 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Wed, 5 Aug 2026 22:45:33 -0400 Subject: [PATCH 1/3] fix(ratings): lower minimum cyclist rating to 50 The `pcm_update_cyclist_ratings` tool rejected ratings below 55, but the actual game minimum is 50. Update the Zod bound and the docs accordingly. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- README.md | 2 +- src/tools/update-cyclist-ratings.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecd528a..56c5454 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ All tools are prefixed with `pcm_`. Read tools carry `readOnlyHint: true` / `des | `pcm_search_team` | Search team by name (partial, case-insensitive; matches full name and short name). | | `pcm_query_save` | Run a single read-only `SELECT`/`WITH … SELECT`. Write/DDL rejected; results capped (default 100, max 1000). | | `pcm_update_save` | Apply a single `INSERT`/`UPDATE`/`DELETE` to a save and write the result to a **new** `.cdb` (`outputPath` must differ from `savePath`). SELECT/DDL/stacked statements rejected. | -| `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 55–85) and write the result to a **new** `.cdb`. Returns the cyclist's full ratings after the update. | +| `pcm_update_cyclist_ratings` | Change one or more `charac_i_*` ratings of a cyclist (by `IDcyclist`, ratings 50–85) and write the result to a **new** `.cdb`. Returns the cyclist's full ratings after the update. | | `pcm_generate_startlist_xml` | Build a PCM startlist XML from teams + rosters; derives the file name from `STA_race.gene_sz_filename` for the given `IDrace`. | ## Conventions diff --git a/README.md b/README.md index 257494d..ca2df6a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ All tools are prefixed with `pcm_`. Every tool except `pcm_update_save` and `pcm | **pcm_search_team** | Search for a team by name (case-insensitive partial match against both the full name and short name). Returns up to 10 matches with the resolved division name, country name, evaluation and general manager; a `truncated` flag signals when more matches exist. | | **pcm_query_save** | Run a read-only SQL query (`SELECT` / `WITH … SELECT` only) against any table in a save file. Write/DDL statements are rejected. Results are capped (default 100, max 1000 rows). | | **pcm_update_save** | Apply a single `INSERT`/`UPDATE`/`DELETE` statement to a save and write the modified database to a **new** `.cdb` at `outputPath`. The source save is never overwritten (`outputPath` must differ from `savePath`); `SELECT`, schema changes (`DROP`/`CREATE`/`ALTER`) and stacked statements are rejected. Returns the written path and the number of rows changed. | -| **pcm_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (`plain`, `mountain`, `mediumMountain`, `downhilling`, `cobble`, `timeTrial`, `prologue`, `sprint`, `acceleration`, `endurance`, `resistance`, `recuperation`, `hill`, `baroudeur`; 55–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. | +| **pcm_update_cyclist_ratings** | Change one or more ability ratings of a cyclist (by `IDcyclist`) and write the modified database to a **new** `.cdb` at `outputPath`. Takes a `ratings` object where each field is optional (`plain`, `mountain`, `mediumMountain`, `downhilling`, `cobble`, `timeTrial`, `prologue`, `sprint`, `acceleration`, `endurance`, `resistance`, `recuperation`, `hill`, `baroudeur`; 50–85) — only the fields provided are changed. Returns the written path and the cyclist's full ratings after the update. Setting `mediumMountain` is rejected on saves that pre-date that column. | | **pcm_generate_startlist_xml** | Generate a PCM startlist XML document from a list of teams and their cyclist rosters. Looks up the race by `IDrace` in the save to derive the output file name from `STA_race.gene_sz_filename` (e.g. `c0_almeria.xml`), and returns both the file name and the XML as text. Team and cyclist IDs map to `DYN_team.IDteam` / `DYN_cyclist.IDcyclist` (look them up with `pcm_search_cyclist` or `pcm_query_save`). | ## How it works diff --git a/src/tools/update-cyclist-ratings.ts b/src/tools/update-cyclist-ratings.ts index 1d0120f..9742a55 100644 --- a/src/tools/update-cyclist-ratings.ts +++ b/src/tools/update-cyclist-ratings.ts @@ -9,7 +9,7 @@ import { } from "../schemas/cyclist"; import { getTableColumnNames, withSaveDb, writeSaveDb } from "../save-db"; -const ratingValue = z.number().int().min(55).max(85); +const ratingValue = z.number().int().min(50).max(85); const newRatingsSchema = z.object({ plain: ratingValue.optional().describe("New plain rating (charac_i_plain)"), @@ -91,7 +91,7 @@ export function registerUpdateCyclistRatings(server: McpServer): void { "ID of the cyclist to modify (DYN_cyclist.IDcyclist — find it with pcm_search_cyclist)", ), ratings: newRatingsSchema.describe( - "Ratings to change (55–85). Only the fields provided are updated; the others keep their current value.", + "Ratings to change (50–85). Only the fields provided are updated; the others keep their current value.", ), }, outputSchema, From 8d6cbe40defd680e3f32d5e69ee0a20ad642c105 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 6 Aug 2026 08:10:39 -0400 Subject: [PATCH 2/3] test(ratings): cover the 50 lower bound Add boundary coverage for the rating range: 50 is accepted end-to-end and 49 is rejected by the input schema with a `too_small` issue on `minimum: 50`. Arguments now go through the registered input schema, since `callTool` invokes the tool callback directly and would otherwise skip validation. Co-Authored-By: Claude Opus 5 --- test/tools/update-cyclist-ratings.test.ts | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts index 28d9eb0..89c08c8 100644 --- a/test/tools/update-cyclist-ratings.test.ts +++ b/test/tools/update-cyclist-ratings.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import initSqlJs from "sql.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; import { registerUpdateCyclistRatings } from "../../src/tools/update-cyclist-ratings"; import { saveFixtures } from "../fixtures/save.fixture"; import type { MockMcpServer } from "../mocks/mock-mcp-server"; @@ -45,6 +46,12 @@ describe("updateCyclistRatings", () => { let mcp: MockMcpServer; let outDir: string; + const inputSchema = () => + z.object(mcp.getTool("pcm_update_cyclist_ratings")?.config.inputSchema); + + const parseArgs = (args: Record) => + inputSchema().parse(args) as Record; + beforeEach(async () => { mcp = createMockMcpServer(); registerUpdateCyclistRatings(mcp.server); @@ -154,6 +161,48 @@ describe("updateCyclistRatings", () => { }, ); + it.each(saveFixtures)( + "accepts the lowest allowed rating (50) for %s", + async (_name, path) => { + const cyclistId = await readFirstCyclistId(path); + const outputPath = join(outDir, "edited.cdb"); + + // `callTool` invokes the callback directly, so the arguments go + // through the registered input schema first — that is where the + // 50–85 bound lives. + const result = await mcp.callTool( + "pcm_update_cyclist_ratings", + parseArgs({ + savePath: path, + outputPath, + cyclistId, + ratings: { sprint: 50 }, + }), + ); + + expect(result.isError).toBeUndefined(); + expect( + await readRatings(outputPath, cyclistId, ["charac_i_sprint"]), + ).toEqual([50]); + }, + ); + + it("rejects a rating below the minimum (49)", () => { + const result = inputSchema().safeParse({ + savePath: "/saves/career.cdb", + outputPath: "/saves/edited.cdb", + cyclistId: 1, + ratings: { sprint: 49 }, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]).toMatchObject({ + path: ["ratings", "sprint"], + code: "too_small", + minimum: 50, + }); + }); + it.each(saveFixtures.filter(([, , hasMediumMountain]) => hasMediumMountain))( "sets mediumMountain for %s", async (_name, path) => { From ea6245fc2146d22381fb3350fe83fc231c7babf6 Mon Sep 17 00:00:00 2001 From: Mathieu Picciolli Date: Thu, 6 Aug 2026 08:10:58 -0400 Subject: [PATCH 3/3] refactor(tests): remove redundant comments in updateCyclistRatings tests --- test/tools/update-cyclist-ratings.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/tools/update-cyclist-ratings.test.ts b/test/tools/update-cyclist-ratings.test.ts index 89c08c8..e7b5bf1 100644 --- a/test/tools/update-cyclist-ratings.test.ts +++ b/test/tools/update-cyclist-ratings.test.ts @@ -167,9 +167,6 @@ describe("updateCyclistRatings", () => { const cyclistId = await readFirstCyclistId(path); const outputPath = join(outDir, "edited.cdb"); - // `callTool` invokes the callback directly, so the arguments go - // through the registered input schema first — that is where the - // 50–85 bound lives. const result = await mcp.callTool( "pcm_update_cyclist_ratings", parseArgs({