[READ-ONLY] Mirror of https://github.com/quarterback/tennis-team-manager. My longheld white whale of a tennis text simulation
README.md

tennis-team-manager #

A college dual-match tennis season simulator and team manager. Simulate matches point by point, advance a living world year over year, and watch rankings, recruiting, conferences, prestige, and the College bracket evolve across D1, D2, D3, and D4 — men's and women's — each its own independent tournament.

It began as a deterministic singles match engine; the original docs/README-genesis.md keeps that starting point. Today it runs as a full web app spanning the match engine, the season and postseason, recruiting, prestige, and a pro tour.

Single-player sandbox, god-mode friendly. One owner runs a whole tennis universe, peeks under the hood, and tweaks anything. Runs are seed-deterministic and reproducible. Injuries roll on real entropy, so save-scumming suits a single-player world — it's your world.


Run #

Needs Python 3.11+. The engine + CLI are pure standard library; the web UI just needs Flask.

python3 -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python3 manage.py runserver --port 8000              # web app at http://localhost:8000

Then open http://localhost:8000. A local SQLite save is created at ./tennis.db automatically (override with TENNIS_DB_PATH=/path/to/save.db). Runs fully offline once the deps are installed — great on a plane.

macOS: don't use the default port 5000 — it's taken by AirPlay Receiver (you'll get a silent failure or a 403). Use --port 8000 as above, or disable AirPlay Receiver in System Settings. Run from the repo root so app/, engine/ and generators/ import.

pip install hangs, or "installs" and Flask still isn't there #

The reliable fix is to throw the virtualenv away and build a fresh one — it's cheap (three small packages) and it clears every cause below at once:

cd ~/path/to/tennis-team-manager      # always from the repo root
rm -rf .venv
python3 -m venv .venv && source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
python3 manage.py runserver --port 8000

Sanity-check that pip is the venv's pip before blaming the install:

which python3 && pip -V     # both paths must be inside .../tennis-team-manager/.venv

What's usually actually wrong:

  • The venv isn't active (a new terminal tab, or the source scrolled away), so pip install goes to the system Python. It succeeds, and the app still can't import Flask.
  • Installing into a distro-managed Python — the giveaway is ERROR: Cannot uninstall blinker … RECORD file not found. Hint: The package was installed by debian. Nothing is broken; pip just refuses to touch an OS-owned package. In a fresh venv it never comes up (pip install --ignore-installed blinker forces past it if you're stuck with the system Python).
  • A stale venv built against a Python you've since upgraded — Homebrew moving 3.12 → 3.14 leaves .venv/bin/python3 pointing at a runtime that no longer exists, and pip appears to hang.
  • A genuine hang is nearly always the network, not pip. pip install --timeout 10 -r requirements.txt fails fast instead of sitting there, and the three deps are small enough that a slow index is obvious.

Rebuilding .venv never touches your save — tennis.db lives in the repo root, not the virtualenv.

To play your deployed universe locally, pull its DB down first (don't run both against it at once):

fly ssh sftp get /data/tennis.db ./tennis.db -a tennis-team-manager

Open the app, start a league, and advance the world week by week. The CLI also drives the engine directly:

python3 manage.py simulate-match --seed 7 --pbp        # one singles match, play-by-play
python3 manage.py simulate-dual  --seed 7              # a College dual (3 doubles + 6 singles)
python3 manage.py season         --seed 2026 --division D1 --gender men
python3 manage.py ita-kickoff    --seed 2026 --gender men   # the opening season tournament
python3 manage.py simulate-gtt   --seed 7              # a co-ed Pro Tour dual
python3 manage.py runserver --port 8000
pytest                                                 # determinism + invariants (~10 min full)

What's in the world #

The match engine (engine/) — point→game→set→match scoring with a serve/rally probability model, a fast game-level model for bulk sims, the College dual format (3 doubles + 6 singles, clinch at 4), doubles, and box-score/play-by-play render. Scoring rules toggle independently (engine/format.py): set and final-set tiebreaks, 8-game pro sets, best-of-3/5, sudden-death deuce, with presets like grand_slam and pro_set_8.

Ratings — every player carries an STR rating, a UTR-style number on a distinctive 31–57 band, solved to a fixed point from results. Teams rank by a Power Index and by ITA-style team points (résumé × schedule × league strength), which feed the bracket.

The season + postseason — a full schedule, conference races, the opening season tournament, conference tournaments (automatic bids), then the College championship. A Committee Seed Score selects and seeds the field (Power Index 45%, ITA points 30%, a tiered automatic-bid championship bonus 15%, recent form 10%), holding selection, seeding, and bracketing as three separate questions. The 96-team D1 field (64 for D2–D4) draws into four S-curve regions with cosmetic rotating names; region champions meet in the national semifinals. Then the individual singles and doubles championships.

The recruiting budget economy — each program earns its roster. A recruiting budget set by conference tier buys recruits that cost by star, from blue-chip down to free walk-on. Blue-bloods fund deep and stack blue-chips; mid- and low-majors build 4★/3★ cores; the top D3/D4 academic programs draw a thin "gem" allocation. The guardrails live in CLAUDE.md as tuned game-design values.

Dynamic prestige — a program's prestige evolves each year by how it performs against expectation, so a low-major that keeps overachieving climbs and recruits up a budget tier, and a sliding blue-blood drifts down. The drift stays self-correcting and bounded, and the journey shows on the Data Portal.

The Analytics Bureau — talent intelligence with zero scouting fog: every player's true ceiling beside where they actually sit (buried studs, aid misallocations, best-fit landing spots), plus a Lineup Lab strip-plot comparing every team's singles ladder across a conference and ranking relative league strength.

The JHSAA — Jefferson, a fictional US state, runs its own high-school tennis association inside this engine: 862 girls'/777 boys' programs across nine classifications (1A-9A) playing a full simulated season, browsable at /jhsaa, whose graduating seniors are Jefferson's entries on the college recruiting board. Its own dual formats, double round-robin district schedule, TOSS-based seeding, a multi-round State postseason with earned (never bye-only) recovery rounds, résumé-based postseason awards (All-State/ All-Region/All-District), program archetypes, and a "playing up" mechanic for small blue-bloods. See docs/DESIGN-jhsaa-high-school-season.md and the many docs/AAR-jhsaa-*.md reports.

More — a season-long recruiting pipeline (a junior pool, signings, the transfer portal), coaches (careers, localism, moves), an ATP/WTA-style Data Portal newsroom, a Pro Tour (GTT) college-to-pro pipeline, program honors / hall of fame / archives, and a live editor to move players, set lineups, and override prestige, academics, and scholarship limits.

The JHSAA — Jefferson's high-school season, simulated and browsable at /jhsaa (districts, brackets, TOSS rankings, awards, program pages). Its graduating seniors feed the college recruit board. For offline analysis of many independent seasons without touching the college/pro world, see JHSAA Lab below.

JHSAA match scorelines are calibrated against five seasons of REAL Oregon high-school results (engine.fast.HS_PROFILE; measured in-game at /jhsaa/realism, offline via scripts/jhsaa_scoreline_benchmark.py). The profile went live in the owner's save at the 2053 season — 2027-2052 were simulated on the old college-calibrated dials, so the realism page's season switcher doubles as the before/after benchmark across that boundary. See docs/AAR-jhsaa-scoreline-realism.md.


JHSAA Lab — simulate standalone high-school seasons #

A separate tool for generating and multi-year-advancing JHSAA seasons on their own, decoupled from the college/pro sim — useful for offline data analysis (many seasons, cohort aging/graduation) without building a whole college world each time.

scripts/jhsaa_lab_server.sh [port]   # database is fixed; default port 5050

Then open http://localhost:<port>/jhsaa-lab. It's always safe to run alongside your real app (default port 5000) — it is bound to the single persistent database ~/.tennis-team-manager/jhsaa_lab.db and the /jhsaa-lab* routes only exist when JHSAA_LAB_MODE=1, which the launcher script sets for you.

Normal JHSAA gameplay has one canonical local database: ~/.tennis-team-manager/jhsaa_lab.db. If that database cannot be used, the app stops. It never creates or falls back to another JHSAA database. Alternate scratch databases require the explicit JHSAA_LAB_DEV_OVERRIDE=1 developer/test path and are never accepted as a launcher argument.

From the page:

  • Generate new season — resets the canonical lab world and starts a fresh multi-year run with a new set of programs/cohorts (optional salt to vary the draw).
  • Advance N years — ages the same cohorts forward (1–50 years at a time), graduating and replacing them, to build a real multi-year history to browse.

Both run in the background and the page polls for progress — expect ~10 minutes per season, cold. That's not a bug: the normal app hides this behind a boot-time cache warm that isn't there in a fresh lab process, so this is the real cost of simulating ~600 programs across both genders.


Layout #

engine/            match engine — state, rally, match, fast, dual, doubles, gtt, format, render, tournament
app/               the world: divisions, programs, rosters, prestige, season + postseason, recruiting, coaches…
  world.py           the living world — year rollover, development, portal, prestige momentum
  seasonmode.py      schedule, results, Power Index, College bracket draw + advancement
  recruit_economy.py the budget-by-tier recruiting economy (costs, floors, bands)
  regions.py         S-curve regional bracket structure + cosmetic region names
  scout_intel.py     Analytics Bureau (underplaced, playing time, fit, Lineup Lab)
  str_rating.py / rating.py   STR + Power Index
  recruiting.py / scholarships.py / economy.py   recruiting + scholarship layers
  gtt_seasonmode.py  the Pro Tour league
  web/               the Flask app — server.py (routes), state.py (data), templates/
docs/              DESIGN doc + ~70 AARs (one per change, the running changelog)
analytics/         The Clinch Report — offline static-site analytics sidecar (see analytics/README.md)
CLAUDE.md          agent guardrails — the recruiting/prestige economy invariants
manage.py          CLI;  wsgi.py / Dockerfile / fly.toml / Procfile  — deploy (Fly)
tests/             determinism, scoring, economy, bracket, world-rollover invariants

Most tests assert invariants and determinism.


Docs #

  • docs/GUIDE.md — the game guide. A complete, sectioned manual to how the game actually plays: divisions and dual formats, the recruiting/scholarship economy, injuries, the transfer portal, rankings, championships, Jefferson, the JHSAA, the pro tour — with an appendix indexing every AAR by topic. Also rendered in-app under Tools → Guide. Point an LLM sidecar here for "how does this game work" questions.
  • CLAUDE.md — the design invariants (the budget economy, walk-on sourcing, injuries, dynamic prestige). Read it before changing numbers.
  • docs/ — an AAR (after-action report) per change documents how each system works and why.
  • docs/README-genesis.md — where this started.