Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/tools/update-cyclist-ratings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
mpicciolli marked this conversation as resolved.

const newRatingsSchema = z.object({
plain: ratingValue.optional().describe("New plain rating (charac_i_plain)"),
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions test/tools/update-cyclist-ratings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>) =>
inputSchema().parse(args) as Record<string, unknown>;

beforeEach(async () => {
mcp = createMockMcpServer();
registerUpdateCyclistRatings(mcp.server);
Expand Down Expand Up @@ -154,6 +161,45 @@ 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");

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) => {
Expand Down
Loading