Skip to main content
Glama
petegibbins

Royals26

by petegibbins
README.md
# Royals26

Scouting and tactics for the **Cambridge Royals** (BBF Division 4, 2026), exposed
to Claude as an MCP server.

All league data comes from [stats.britishbaseball.org.uk](https://stats.britishbaseball.org.uk),
a WBSC-hosted site. This tool only ever **reads** from it.

## How it works (the agentic part)

Royals26 is a **Model Context Protocol (MCP) server**: it exposes a set of typed,
read-only **tools** (scouting reports, matchups, lineup prediction, running-game
and small-ball tendencies, playoff projection) that an LLM agent — Claude — can
**call on demand** to reason about a live dataset it was never trained on. The
model supplies the language and the judgement; the server supplies retrieval,
computation and guardrails.

- **Tool-calling agent design** — each capability is a discrete tool with a clear
  contract, so the model composes them itself (*"scout Durham Spartans"* fans out
  into several tool calls).
- **Grounded, not hallucinated** — every tool returns its **sample size and
  caveats**, and the data layer is verified against a second source before
  anything is built on it, so the agent is kept from overclaiming on thin data.
- **Pipeline behind the tools** — a throttled fetch/parse crawler with block
  detection (`fetch.py`, `parse.py`) caches the source into SQLite (`store.py`),
  an analysis layer computes the domain logic (`analysis/`), and `server.py`
  wraps it all as MCP tools.

## Quick start

```sh
python -m venv .venv && .venv/bin/pip install mcp
.venv/bin/python -m royals26.sync          # first run: ~20 minutes
claude mcp add royals26 -s user -- /home/peterg/Code/Royals26/.venv/bin/python -m royals26.server
```

Then ask Claude things like *"scout Durham Spartans"*, *"predict the Herts Hawks
lineup"*, *"who should bat leadoff if Kung isn't available?"*.

**→ [USAGE.md](USAGE.md) is the guide to actually using it**: the full tool
reference, how to keep the cache current, and what not to trust. The rest of
this file is about where the data comes from and how far it can be pushed.

## What the data actually supports

Verified against the live site before building anything on it.

| Available | Notes |
|---|---|
| **Pitch-by-pitch** | One row per pitch: count, outs, runners, batter, pitcher, and a human-readable narrative. |
| **Pitch outcome** | `result1`: 2 ball, 3 hit-by-pitch, 4 called strike, 5 swinging strike, 6 foul, 9 in play. Decoded by cross-referencing thousands of narratives. |
| **Lineup cards** | Batting slot, position and substitution flag for every player in every game. |
| **Handedness** | Bats/throws per player, from rosters and box scores. |
| **Season leaderboards** | An undocumented JSON endpoint returns full-season batting, pitching and fielding for all 686 players. |
| **Batted-ball type** | `hittype`: 1 ground ball, 2 line drive, 3 fly ball, 4 popup. |
| **Platoon splits** | Not from the site — computed here by joining each plate appearance to the pitcher's handedness. 1,488 PA vs LHP league-wide against 13,409 vs RHP, so vs-LHP samples are small and flagged. |
| **Hit location** | `hitdistance` is populated on **99.4%** of balls in play — but it is the scorer's finger on a field diagram, not a measurement. Too coarse to compare two hitters. See the caveat below. |

| Not available | Consequence |
|---|---|
| **Pitch location** | `pitchheight`/`pitchoutside` look like coordinates but are derived from the call: every pitch in the x-band 30–39 is a ball (100%, n=14,347) and every pitch in −20…19 is a strike or in play (0% balls, n=14,514). Location carries no independent information, so **there are no zone maps**. |
| **Hit coordinates** | `hitx`/`hity` are zero in every game sampled. **No spray charts.** Batted-ball type and `hitdistance` (below) are the substitutes. |
| **Pitch type** | `pitchtype` is `-1` throughout. No pitch-mix analysis. |
| **Exit velocity** | Never populated. |
| **The site's split filters** | The stats page advertises 24 splits (vs_left, scoring_position, ahead_count…). Every one returns `"data":[]` — the filters render but the backend has nothing behind them. Only the unsplit query works, so the sync no longer requests the other 69. |

Anything the tools report carries its sample size. Division 4 is amateur Sunday
baseball — a rate over 20 plate appearances is a hint, not a fact.

### Games are five innings — except in the playoffs

Worth knowing before reading any per-game number. **League games are scheduled
for five innings and teams play two of them on a game day.** The playoffs are
longer, and not uniformly:

| Round | Date | Innings | Format |
|---|---|--:|---|
| Quarter-final | Sat 5 Sep | **9** | single game |
| Semi-final | Sun 13 Sep | **9** | single game |
| Final | Sat 19 – Sun 20 Sep | **7** | best of three at Farnham, two on the Sunday |

The final is shorter because two of its three games are played on the same day.
A team going all the way plays **up to 39 innings in 16 days**, against a
regular season that has never asked for more than 10 in a day.

**The run-difference rule means those innings may not all be played.** A game
ends early once the losing side has completed this many offensive innings:

| Lead | In a 9-inning game | In a 7-inning game |
|--:|--:|--:|
| 10 runs | 7 | 5 |
| 15 runs | 5 | 4 |
| 20 runs | — | 3 |

Note there is **no 20-run rule in a nine-inning game**. This matters for pitching
plans: against winning opposition the Royals' margin runs at +1.08 an inning,
so roughly one game in three is on a pace to finish at the seventh — which
argues for using the best arm early rather than saving it for a ninth inning
that may never arrive.

Across 302 completed league games:

| Innings | Games | What it means |
|--:|--:|---|
| 3 | 3 | abandoned |
| 4 | 105 | stopped on the time limit or the run rule |
| 5 | 161 | regulation |
| 6–7 | 33 | extras |

Two consequences. Every per-game rate the tools report — runs, steals, pitches —
is a **five-inning** rate, so a nine-inning quarter-final is worth about 80%
more of each. And a "complete game" here is five innings: nobody on the Royals'
staff has thrown more than that, or more than 120 pitches, which makes a
nine-inning game a **three-pitcher** game by arithmetic rather than by choice.

## How good is the lineup prediction, really?

Back-tested over all 32 teams and 444 games, predicting each game from only the
games that preceded it (`python -m royals26.backtest "Cambridge Royals"`):

| Measure | League | Cambridge Royals |
|---|--:|--:|
| Named the right nine starters | **64%** | 68% |
| Put a starter in the right slot | **27%** | 32% |

And the uncomfortable part: **copying the previous game's card scores .268/.629**
— statistically the same. The recency weighting earns almost nothing, and a
tuning sweep showed nothing longer than a one-game half-life helps at all.

So: treat the nine names as a reasonable guess, the batting order as a weak one,
and pay attention to the per-slot alternatives — those are what tell you where a
team is unsettled. Nobody should plan a defensive alignment around slot 6.

## Data quality in the source

`python -m royals26.verify` cross-checks two independent endpoints against each
other. Over 298 games with detail (286 of which have a box score):

* final score agrees with the sum of per-player runs in **278**; the eight gaps
  are runs their scorer never attributed to a batter, plus one officially
  overridden result;
* plate-appearance counts derived from the play stream match the box-score
  column in **289**;
* every play resolves to a real batter and pitcher;
* 4 of 596 team-games list something other than nine starters;
* every team's W-L in the standings matches the record computed from the game
  results — all 32 agree exactly.

### There is a five-run cap per inning, lifted in the last

Not documented anywhere, and it shapes every scoring number in this project.
Across 3,253 half-innings the distribution of runs scored is smooth from 0 to 4,
then piles up at exactly 5:

| Runs in a half-inning | Innings 1 to N−1 | Final inning |
|--:|--:|--:|
| 4 | 7.8% | 8.0% |
| **5** | **20.9%** | 7.2% |
| 6 | 0.5% | 3.1% |
| 7+ | 0.1% | 5.9% |

Outside the final inning, a half-inning almost never scores more than five —
61 of the 77 six-plus innings all season are in the last inning of a game. That
is a five-run cap with an open final inning, and it has two consequences worth
holding onto:

* **It compresses scoring differences and helps the weaker side.** A team whose
  underlying rate is 3.3 runs an inning loses 30% of them to the cap; one at
  2.2 loses 19%. Being much better on offence is worth less here than the raw
  rates suggest.
* **Any simulation has to model it.** Fitting runs per inning without the cap
  produces blowouts that cannot happen and badly overstates how often the
  run-difference rule fires.

### `hitdistance` is a scorer's finger on a diagram

Populated on 99.4% of balls in play, and tempting to read as batted-ball
quality. Don't. **The scorer taps roughly where the ball went on a field map**,
and the app converts that to a distance — so the values cluster hard on a
handful of numbers (179 ft appears 2,552 times, 113 ft 1,375, 94 ft 1,050) and
two singles to left centre both read 179 ft whether one was scorched and the
other looped.

It is coarse by construction, inconsistently applied, and per the club
sometimes given lip service. Measured the same way as the called-strike
problem, its game-to-game spread is **1.5× what chance allows** (sd 12.9%
against a binomial 8.6%, range 22.6% to 84.8% of balls reaching 150 ft).

It is not useless — it correlates sensibly with outcomes (outs average 114 ft,
hits 182, doubles 230, home runs 296) and a player's figure draws on many
different games, so scorer noise partly averages out. But on the sample sizes
this squad produces, the confidence intervals are far too wide to separate two
hitters. **For contact quality, count extra-base hits instead**: a double is a
runner on second base, recorded as an outcome with no judgement in it.

### Called vs swinging strikes are scorer opinion, not fact

Games are scored by different volunteers, and the distinction between a called
and a swinging strike is not applied consistently. Across the 287 games with 40
or more of them:

| | |
|---|---|
| Share scored **called** | mean 61.3%, sd 13.4% |
| Range | **0% to 86%** |
| Expected sd if scoring were consistent | 6.6% — observed is **2.0×** that |

Two games record **zero** called strikes across 46 and 59 strikes respectively.
That is not baseball; it is a scorer who never pressed that button. Confirmed
independently by a player whose swinging strikeout on 16 Aug is recorded as
looking — in a game scored 76% called against his own season average of 61%.

What this does and does not break:

* **Ball versus strike is a different call and is reliable** — so first-pitch
  strike rates, counts, walk rates and everything derived from the count are
  sound.
* **Called versus swinging is not** — so `whiff_per_swing`, swing rates and any
  "how passive is this hitter" number carry scorer noise. Treat a swing rate as
  a range, not a figure. Where a claim depends on it, test it against the
  unambiguous events instead: **fouls and balls in play cannot be confused with
  a take**.

The first-pitch take/swing break-even was re-run three ways — as scored, with
swinging strikes dropped, and with every swinging strike treated as a take. The
break-even moves from 65.0% to 66.8% to 71.1% and the conclusion holds in all
three, because it rests on ball-versus-strike rather than on how the strike was
taken.

### `gamestatus` is not a boolean

Worth knowing if you touch the queries. The site uses four values, and the
obvious reading of "3 means final" is wrong:

| Value | Meaning | Count |
|--:|---|--:|
| 0 | fixture not yet played | 80 |
| 2 | final, recently played | 18 |
| 3 | final | 266 |
| 4 | forfeit — always 7-0, no box score at all | 12 |

Filtering on `status = 3` silently hid 32 completed games including everything
played in the preceding fortnight, and `status != 3` reported those same games
as upcoming fixtures. Use `PLAYED_STATUSES` for "has this been played" and
`BOX_SCORE_STATUSES` for "is there a box score to fetch".

## Rate limiting

The site sits behind CloudFront with a rate-based WAF rule. A full crawl at
1 request/second earned a blanket 403 across the whole site after ~270 requests
(box score pages are ~1.4 MB each). The default is now **3 seconds plus jitter**,
and raw payloads are cached in `raw_box`, so:

* a full crawl only ever happens once — parser fixes re-run offline via
  `sync.reparse_all()`;
* completed games are immutable and never re-fetched;
* on a 403 the sync stops immediately rather than working through the queue,
  and resumes where it left off.

Override with `ROYALS26_REQUEST_INTERVAL=5` if you want to be gentler still.

## Layout

```
royals26/
├── fetch.py            HTTP with throttling, jitter and block detection
├── parse.py            Inertia data-page extraction, roster tables
├── store.py            SQLite schema and upserts
├── corrections.py      Local fixes to wrong source data, re-applied on write
├── render_pdf.py       reports/*.md -> print-ready A4 PDF via headless Chromium
├── sync.py             Orchestration; reparse_all() works offline
└── analysis/
    ├── common.py       Team/player resolution by id, code or name
    ├── lineups.py      Lineup prediction, probable pitcher, batting order
    ├── pitching.py     Stamina curves, count behaviour, hitter profiles
    ├── tactics.py      Running game, count tendencies, small ball, splits
    ├── playoffs.py     Bracket and seed projection
    └── scouting.py     The composed pre-game report
data/
├── league.db           The cache
├── corrections.json    Player details the site has wrong
└── playoffs.json       Snapshot of the "Baseball26" Google Sheet
reports/                Game reports as markdown, plus rendered PDFs
```

## Playoffs

The bracket is not on the BBF site — it lives in a Google Sheet, snapshotted to
`data/playoffs.json`. On standings as at **17 Aug** the Royals are the **2 seed
and host QF2 on Sat 5 Sep against Bristol Buccaneers**, the 7 seed — then, if
they win, host **SF2 on Sun 13 Sep** against whoever comes out of QF3 (Plymouth
Outlaws 1 or Durham Spartans). The home semi-final belongs to the QF1/QF2 winner
by bracket position, not by seed, so seeds 1 and 2 both host throughout.

### The tiebreak is runs conceded

Cambridge and Herts Hawks are both 21-1 (.955). The league handbook breaks a
win-percentage tie on **runs conceded, fewest first**, and Herts have allowed 88
to Cambridge's 137 — so Herts take the 1 seed and Cambridge the 2.

`projection()` implements this: seeds sort on `(-pct, runs_conceded)`, and the
result reports `ties_settled_on_runs_conceded` alongside any that are still
level on both. Before the rule was known it sorted on percentage alone, which
let ties fall out of database row order and quietly invented a seed order —
worth remembering as the failure mode of any "projection" that has no rule
behind it.

The practical consequence is the semi-final, not the quarter. Seed 2 draws
Bristol (17-7) rather than Sheffield Bladerunners 2 (14-8) in the quarter, which
is close to a wash — but it puts **Plymouth Outlaws 1 (21-3)**, the strongest
side outside the top two, in the same half.

The seeding rule is stated on the league's fixture sheet, which lists the
quarter-finals by seed rather than by team: division winners seed **1–6 by
win%**, then the **SWWBL and North runners-up** take seeds 7–8, also by win%.
Pairing is the conventional **1v8, 2v7, 3v6, 4v5**, higher seed hosting.

The team names typed into the bracket page of that sheet **do not follow from
this rule** — Bracknell and Durham are division winners sitting where seeds 7–8
must be runners-up. They're placeholders from before the standings settled, so
`playoff_projection` (seed-based, off live standings) is what the tools report
and `playoff_bracket` flags the disagreement. Ask Claude to re-read the sheet
when it changes.

TDQS

B3/5.0

Scored across 25 tools

Disambiguation4/5

Most tools target clearly distinct resources or analytical questions, and the descriptions help separate overlapping areas like hitting stats versus scouting. A few pairs—predict_lineup/suggest_batting_order and scout_report/scout_report_data—could cause hesitation, but their descriptions clarify the difference.

Naming Consistency3/5

Tool names are readable and descriptive, but they mix styles: verb_noun names like list_teams and sync_recent sit alongside bare nouns like standings and roster, and compound names like small_ball or team_pitching. There is no single consistent convention, but the patterns are not chaotic.

Tool Count3/5

At 25 tools the surface is on the heavy side, though the domain—standings, rosters, stats, scouting, and playoffs—is broad enough that most tools serve a distinct purpose. A few duplicative formats and cache-maintenance tools could be trimmed, but the count is not unreasonable.

Completeness4/5

The toolkit covers the core analytical workflow well: standings, schedules, rosters, batting and pitching stats, player profiles, matchup history, scouting reports, and playoff scenarios. Minor gaps exist such as no per-game box score retrieval or transaction/roster-move tools, but for a read-only analysis server this is solid coverage.

Maintenance

ActivityMaintained
ResponsivenessNo issues