spudcoach 0.9.0__tar.gz
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.
- spudcoach-0.9.0/.github/workflows/publish.yml +31 -0
- spudcoach-0.9.0/.gitignore +28 -0
- spudcoach-0.9.0/AGENTS.md +1 -0
- spudcoach-0.9.0/CLAUDE.md +30 -0
- spudcoach-0.9.0/LICENSE +21 -0
- spudcoach-0.9.0/PKG-INFO +12 -0
- spudcoach-0.9.0/README.md +271 -0
- spudcoach-0.9.0/brotato_coach/__init__.py +1 -0
- spudcoach-0.9.0/brotato_coach/answers.py +176 -0
- spudcoach-0.9.0/brotato_coach/builders/__init__.py +0 -0
- spudcoach-0.9.0/brotato_coach/builders/characters.py +42 -0
- spudcoach-0.9.0/brotato_coach/builders/discover.py +161 -0
- spudcoach-0.9.0/brotato_coach/builders/items.py +65 -0
- spudcoach-0.9.0/brotato_coach/builders/localization.py +33 -0
- spudcoach-0.9.0/brotato_coach/builders/mechanics.py +114 -0
- spudcoach-0.9.0/brotato_coach/builders/procs.py +43 -0
- spudcoach-0.9.0/brotato_coach/builders/sets.py +20 -0
- spudcoach-0.9.0/brotato_coach/builders/weapons.py +95 -0
- spudcoach-0.9.0/brotato_coach/calc.py +75 -0
- spudcoach-0.9.0/brotato_coach/dataset.py +65 -0
- spudcoach-0.9.0/brotato_coach/evaluate.py +58 -0
- spudcoach-0.9.0/brotato_coach/query.py +98 -0
- spudcoach-0.9.0/brotato_coach/runfile.py +138 -0
- spudcoach-0.9.0/brotato_coach/schemas.py +45 -0
- spudcoach-0.9.0/brotato_coach/server.py +288 -0
- spudcoach-0.9.0/brotato_coach/tres.py +142 -0
- spudcoach-0.9.0/build_dataset.py +101 -0
- spudcoach-0.9.0/docs/extraction-setup.md +11 -0
- spudcoach-0.9.0/docs/proc-mechanics.md +116 -0
- spudcoach-0.9.0/docs/roadmap.md +44 -0
- spudcoach-0.9.0/docs/run-postmortem-methodology.md +16 -0
- spudcoach-0.9.0/docs/stat-mechanics.md +27 -0
- spudcoach-0.9.0/docs/superpowers/plans/2026-07-01-brotato-coach-theorycrafter.md +1998 -0
- spudcoach-0.9.0/docs/superpowers/plans/2026-07-02-roadmap-implementation.md +1409 -0
- spudcoach-0.9.0/docs/superpowers/plans/2026-07-04-pypi-publish.md +178 -0
- spudcoach-0.9.0/docs/superpowers/specs/2026-07-01-brotato-coach-design.md +161 -0
- spudcoach-0.9.0/docs/superpowers/specs/2026-07-04-pypi-publish-design.md +66 -0
- spudcoach-0.9.0/docs/weapon-merge-dps-methodology.md +15 -0
- spudcoach-0.9.0/plugin/.mcp.json +9 -0
- spudcoach-0.9.0/pyproject.toml +31 -0
- spudcoach-0.9.0/tests/__init__.py +0 -0
- spudcoach-0.9.0/tests/test_answers.py +164 -0
- spudcoach-0.9.0/tests/test_build_characters.py +69 -0
- spudcoach-0.9.0/tests/test_build_discover.py +119 -0
- spudcoach-0.9.0/tests/test_build_items.py +53 -0
- spudcoach-0.9.0/tests/test_build_localization.py +36 -0
- spudcoach-0.9.0/tests/test_build_weapons.py +168 -0
- spudcoach-0.9.0/tests/test_calc.py +66 -0
- spudcoach-0.9.0/tests/test_dataset.py +41 -0
- spudcoach-0.9.0/tests/test_evaluate.py +65 -0
- spudcoach-0.9.0/tests/test_mechanics.py +26 -0
- spudcoach-0.9.0/tests/test_query.py +62 -0
- spudcoach-0.9.0/tests/test_run_report.py +121 -0
- spudcoach-0.9.0/tests/test_runfile.py +140 -0
- spudcoach-0.9.0/tests/test_server.py +187 -0
- spudcoach-0.9.0/tests/test_shipped_dataset.py +47 -0
- spudcoach-0.9.0/tests/test_smoke.py +4 -0
- spudcoach-0.9.0/tests/test_tres.py +43 -0
- spudcoach-0.9.0/tools/brotato_inspect.py +100 -0
- spudcoach-0.9.0/unpack_pck.py +78 -0
- spudcoach-0.9.0/uv.lock +765 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v5
|
|
20
|
+
|
|
21
|
+
- name: Install dependencies
|
|
22
|
+
run: uv sync
|
|
23
|
+
|
|
24
|
+
- name: Run tests
|
|
25
|
+
run: uv run pytest
|
|
26
|
+
|
|
27
|
+
- name: Build package
|
|
28
|
+
run: uv build
|
|
29
|
+
|
|
30
|
+
- name: Publish to PyPI
|
|
31
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Derived/regenerable from a real game install — see README for extraction steps
|
|
2
|
+
extracted/
|
|
3
|
+
recovered/
|
|
4
|
+
|
|
5
|
+
# Copyrighted game binaries — copy from your own Steam install, don't commit
|
|
6
|
+
game_files/
|
|
7
|
+
|
|
8
|
+
# Local Claude Code settings (personal, not shared)
|
|
9
|
+
.claude/settings.local.json
|
|
10
|
+
|
|
11
|
+
# Git worktrees for in-progress work
|
|
12
|
+
.worktrees/
|
|
13
|
+
|
|
14
|
+
# Python artifacts
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.pyc
|
|
17
|
+
.venv/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
|
|
20
|
+
# Derived game-stat dataset — not redistributed; regenerate with build_dataset.py
|
|
21
|
+
# from your own Brotato install (see README).
|
|
22
|
+
data/brotato.json
|
|
23
|
+
|
|
24
|
+
# Brotato run saves used as coach input — game-derived, never redistributed.
|
|
25
|
+
data/run*.json
|
|
26
|
+
|
|
27
|
+
# Local game-data archives (extraction bundles) — never redistributed.
|
|
28
|
+
*.tar
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CLAUDE.md
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Brotato Coach / datamining — project guide
|
|
2
|
+
|
|
3
|
+
Two deliverables live here:
|
|
4
|
+
1. A datamining archive of Brotato (`extracted/` data + `recovered/` decompiled code).
|
|
5
|
+
2. The **Brotato coach** — a deterministic theorycrafter shipped as an MCP server
|
|
6
|
+
(Python package `brotato_coach`), public at https://github.com/BrendanL79/spud-coach (MIT).
|
|
7
|
+
|
|
8
|
+
## Build & test
|
|
9
|
+
- Python 3.11+, managed with **uv**. `uv sync` to set up; `uv run pytest` to test
|
|
10
|
+
(TDD is the norm — write the failing test first).
|
|
11
|
+
- Build the dataset: `uv run python build_dataset.py --game-version <ver> --generated-at <iso8601>`.
|
|
12
|
+
Both args are **required** — `generated_at` is passed in, never read from a clock, so builds
|
|
13
|
+
stay reproducible.
|
|
14
|
+
- Run the MCP server: `uv run python -m brotato_coach.server` (cwd must be the repo root).
|
|
15
|
+
|
|
16
|
+
## CRITICAL: never commit or redistribute the dataset
|
|
17
|
+
`data/brotato.json` is derived from Brotato's copyrighted game files. It is **gitignored and was
|
|
18
|
+
purged from git history**; the public repo ships zero game data. Do NOT re-add or commit it —
|
|
19
|
+
regenerate it locally via `build_dataset.py` from your own extraction. Same for `extracted/`,
|
|
20
|
+
`recovered/`, and `game_files/` (all gitignored).
|
|
21
|
+
|
|
22
|
+
## Architecture (one-way data flow)
|
|
23
|
+
- The **build step** (`build_dataset.py` + `brotato_coach/builders/`) is the only code that reads
|
|
24
|
+
`extracted/`.
|
|
25
|
+
- The **MCP server** reads only `data/brotato.json` — never `.tres`. Keep this separation.
|
|
26
|
+
- Pure logic (`calc.py`, `query.py`, `answers.py`, `evaluate.py`) has no I/O and is unit-tested
|
|
27
|
+
against hand-verified values; server tools are thin wrappers over it.
|
|
28
|
+
- Game-mechanics reference docs are in `docs/`.
|
|
29
|
+
|
|
30
|
+
Now say: "I've reviewed the project memory."
|
spudcoach-0.9.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brendan LeFebvre
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spudcoach-0.9.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spudcoach
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Deterministic Brotato theorycrafter core + MCP server (spud coach)
|
|
5
|
+
Project-URL: Homepage, https://spudcoach.fyi
|
|
6
|
+
Project-URL: Repository, https://github.com/BrendanL79/spud-coach
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: mcp>=1.2.0
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# Brotato Coach
|
|
2
|
+
|
|
3
|
+
A deterministic theorycrafter for [Brotato](https://store.steampowered.com/app/1942280/Brotato/),
|
|
4
|
+
delivered as an MCP server you can chat with from Claude Code (and other MCP clients).
|
|
5
|
+
|
|
6
|
+
The design principle: **a deterministic core holds the ground truth** — weapon/item/character
|
|
7
|
+
data, DPS formulas, stat mechanics — so the language model *looks facts up and computes* instead
|
|
8
|
+
of recalling (and misremembering) them. Every tool returns a finished, verifiable answer or a
|
|
9
|
+
structured error; there are no baked-in tier lists or opinions, only facts and math.
|
|
10
|
+
|
|
11
|
+
The dataset (`data/brotato.json`) is **not committed** — it is derived from copyrighted game
|
|
12
|
+
files, so you build it yourself from a local Brotato install (see [Building the dataset](#building-the-dataset)).
|
|
13
|
+
A full build from **Brotato 1.1.15.4** contains **202 weapons, 197 items, 50 characters, and 15
|
|
14
|
+
weapon-class sets**.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Python **3.11+**
|
|
19
|
+
- [`uv`](https://docs.astral.sh/uv/) for environment/dependency management
|
|
20
|
+
|
|
21
|
+
Install dependencies:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv sync
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Build the dataset (needs a local extraction — see [Building the dataset](#building-the-dataset)),
|
|
30
|
+
then start the server:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv run python build_dataset.py --game-version 1.1.15.4 \
|
|
34
|
+
--generated-at $(date -u +%Y-%m-%dT%H:%M:%SZ) # writes data/brotato.json
|
|
35
|
+
uv run python -m brotato_coach.server # starts the MCP server over stdio
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The server refuses to start without `data/brotato.json` and tells you to build it first.
|
|
39
|
+
|
|
40
|
+
Run the tests (the dataset-dependent integration test is skipped when no dataset is built):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv run pytest # 89 tests (88 passed + 1 skipped without a built dataset)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Run
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uvx spudcoach --data /path/to/brotato.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The dataset is never distributed — build your own from your Brotato install:
|
|
53
|
+
`uv run python build_dataset.py --game-version <ver> --generated-at <iso8601>`
|
|
54
|
+
(see [docs/extraction-setup.md](docs/extraction-setup.md)). `SPUDCOACH_DATA` works as an env-var alternative
|
|
55
|
+
to `--data`.
|
|
56
|
+
|
|
57
|
+
## Use as a Claude Code plugin
|
|
58
|
+
|
|
59
|
+
The MCP server is described by [`plugin/.mcp.json`](plugin/.mcp.json):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"brotato-coach": {
|
|
65
|
+
"command": "uv",
|
|
66
|
+
"args": ["run", "python", "-m", "brotato_coach.server"],
|
|
67
|
+
"cwd": "${CLAUDE_PLUGIN_ROOT}"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The server reads the (locally built) `data/brotato.json` relative to its working directory, so
|
|
74
|
+
**it must run with the repository root as its `cwd`** (the manifest handles this via
|
|
75
|
+
`${CLAUDE_PLUGIN_ROOT}` when bundled as a plugin), and you must
|
|
76
|
+
[build the dataset](#building-the-dataset) first.
|
|
77
|
+
|
|
78
|
+
To register it directly in Claude Code without packaging, add the server pointed at your checkout,
|
|
79
|
+
e.g.:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
claude mcp add brotato-coach -- uv run --directory /path/to/brotato-exam python -m brotato_coach.server
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Once connected, just ask in natural language — the model routes your question to the tools below:
|
|
86
|
+
|
|
87
|
+
- *"Does Handcuffs fit my Ranger run? I'm at 7 ranged damage, 65 HP."*
|
|
88
|
+
- *"Minigun T4 vs Revolver T4 at 20 ranged damage — which hits harder?"*
|
|
89
|
+
- *"Is attack speed ever dead weight? Can I let knockback go negative on a gun build?"*
|
|
90
|
+
- *"What does the Ranger's ranged-damage bonus do to a raw stat of 6?"*
|
|
91
|
+
- *"Here's my run.json — how's this build doing?"* (post-mortem a whole save at once)
|
|
92
|
+
|
|
93
|
+
## Use with Claude Desktop
|
|
94
|
+
|
|
95
|
+
Claude Desktop can launch the server with [`uvx`](https://docs.astral.sh/uv/) in two forms: fetch
|
|
96
|
+
straight from this repo (auto-updates on restart, but needs `git` reachable — see the Windows note
|
|
97
|
+
below), or point at a **local checkout** (nothing fetched at runtime; the most reliable form on
|
|
98
|
+
Windows). Either way you supply your own locally built `brotato.json` — the dataset is never
|
|
99
|
+
distributed.
|
|
100
|
+
|
|
101
|
+
1. Install `uv` on the machine running Claude Desktop (`winget install astral-sh.uv` on Windows,
|
|
102
|
+
or the [standalone installer](https://docs.astral.sh/uv/getting-started/installation/)).
|
|
103
|
+
2. Open the config file and add the `spud-coach` server:
|
|
104
|
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
105
|
+
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
106
|
+
|
|
107
|
+
Fetch-from-repo form:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"mcpServers": {
|
|
112
|
+
"spud-coach": {
|
|
113
|
+
"command": "uvx",
|
|
114
|
+
"args": [
|
|
115
|
+
"--from", "git+https://github.com/BrendanL79/spud-coach",
|
|
116
|
+
"spudcoach",
|
|
117
|
+
"--data", "C:\\Users\\<you>\\path\\to\\brotato.json"
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Point `--data` at your built dataset (`SPUDCOACH_DATA` works as an env-var alternative). On macOS
|
|
125
|
+
use a POSIX path like `/Users/<you>/brotato.json`.
|
|
126
|
+
3. Fully restart Claude Desktop (quit from the tray, not just close the window).
|
|
127
|
+
|
|
128
|
+
### Windows: "Git executable not found" (or `uvx` not found)
|
|
129
|
+
|
|
130
|
+
The `git+https://…` form makes uv shell out to a `git` executable. **Claude Desktop does not pass
|
|
131
|
+
your shell — or even your System `PATH` — to the MCP subprocess;** it spawns servers with its own
|
|
132
|
+
trimmed environment. (The `PATH` it prints in the logs is its command-resolution list, *not* what
|
|
133
|
+
the child process receives.) So a `git` that runs fine in PowerShell, installed in a System-`PATH`
|
|
134
|
+
directory, can still come back "not found" here — and a bare `"command": "uvx"` can fail to resolve
|
|
135
|
+
for the same reason. Two fixes:
|
|
136
|
+
|
|
137
|
+
- **Point at a local checkout — no runtime git (recommended).** Clone once in a terminal where git
|
|
138
|
+
works, then use `--from <folder>` instead of `--from git+…`:
|
|
139
|
+
|
|
140
|
+
```powershell
|
|
141
|
+
git clone https://github.com/BrendanL79/spud-coach C:\Users\<you>\src\spud-coach
|
|
142
|
+
```
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"mcpServers": {
|
|
146
|
+
"spud-coach": {
|
|
147
|
+
"command": "uvx",
|
|
148
|
+
"args": ["--from", "C:\\Users\\<you>\\src\\spud-coach", "spudcoach",
|
|
149
|
+
"--data", "C:\\Users\\<you>\\path\\to\\brotato.json"]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Update later with `git pull` in that folder, then restart Desktop.
|
|
156
|
+
|
|
157
|
+
- **Or force the tools onto the server's `PATH`.** Keep the `git+https` form and add an `env` block
|
|
158
|
+
that hands the child an explicit `PATH` — git, plus uv's bin and the winget-links dir:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
"env": {
|
|
162
|
+
"PATH": "C:\\Program Files\\Git\\cmd;C:\\Users\\<you>\\.local\\bin;C:\\Users\\<you>\\AppData\\Local\\Microsoft\\WinGet\\Links;C:\\Windows\\System32"
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If `uvx` itself still isn't found, also set `"command"` to its absolute path (`Get-Command uvx` to
|
|
167
|
+
locate it, e.g. `C:\Users\<you>\AppData\Local\Microsoft\WinGet\Links\uvx.exe`).
|
|
168
|
+
|
|
169
|
+
## Available tools
|
|
170
|
+
|
|
171
|
+
All tools return a JSON object. Lookups that miss return `{"error": "not_found", "did_you_mean": [...]}`.
|
|
172
|
+
|
|
173
|
+
| Tool | Arguments | Returns |
|
|
174
|
+
|------|-----------|---------|
|
|
175
|
+
| `get_weapon` | `name`, `tier?` | Weapon record incl. precomputed DPS line, on-hit `effects`, and weapon-class `sets`; `{matches:[...]}` if `tier` omitted and several tiers match |
|
|
176
|
+
| `get_item` | `name` | Item record: effects, tags, `archetype`, `frozen_stat` |
|
|
177
|
+
| `get_character` | `name` | Character kit: `wanted_tags`, `banned_item_groups`, `flat_bonuses`, `gain_modifiers`, `special_effects` |
|
|
178
|
+
| `get_weapon_class_set` | `class_name` | Weapon-**class** set bonuses (Blade, Gun, Elemental, …), by equipped count |
|
|
179
|
+
| `loadout_set_bonuses` | `weapon_names` | Per-class set progress across a whole loadout: equipped count, active bonuses, and next threshold |
|
|
180
|
+
| `list_weapons` | `scaling_stat?`, `tier?` | `{weapons:[...]}` filtered summaries |
|
|
181
|
+
| `list_items` | `tag?`, `scaling_stat?`, `archetype?`, `tier?` | `{items:[...]}` filtered summaries |
|
|
182
|
+
| `get_filter_options` | — | Valid filter values in the dataset: item tags, archetypes, scaling stats, tiers, and weapon-class names |
|
|
183
|
+
| `weapon_dps` | `name`, `tier`, `stats` | Realized DPS at the given stats, with breakdown |
|
|
184
|
+
| `compare_weapons` | `names_with_tiers`, `stats` | `{ranking:[...]}` sorted by DPS descending |
|
|
185
|
+
| `compare_merge_paths` | `weapon_name`, `path_a`, `path_b` | Winner or crossover RD for two merge paths (lists of tiers) |
|
|
186
|
+
| `explain_stat` | `stat` | Verified stat mechanics: caps, special behavior, neglectable / never-negative flags |
|
|
187
|
+
| `stat_display_value` | `character`, `stat`, `raw_value` | Displayed value after the character's gain modifiers (e.g. Ranger RD 6 → 9) |
|
|
188
|
+
| `evaluate_item_for_build` | `item_name`, `character_name`, `current_stats` | Per-effect verdict — **live / wasted / harmful** — with reasons, plus a summary |
|
|
189
|
+
| `evaluate_run` | `path?` **or** `run_json?` | One-call post-mortem of a whole Brotato `run.json` save: run context (character, wave, danger), realized stats, weapon-DPS ranking, set progress, and per-item verdicts |
|
|
190
|
+
| `check_dataset_version` | — | `game_version`, `generated_at`, `schema_version` |
|
|
191
|
+
|
|
192
|
+
`stats` / `current_stats` are objects keyed by short stat name (e.g. `{"ranged_damage": 7, "max_hp": 65}`).
|
|
193
|
+
`names_with_tiers` is a list of `[name, tier]` pairs. `path_a` / `path_b` are lists of tier numbers.
|
|
194
|
+
Note the two stat-name forms: `stats` / `current_stats` use the **short** name (`ranged_damage`), while the `stat` argument of `explain_stat` and `stat_display_value` uses the **`stat_`-prefixed** form (`stat_ranged_damage`). `get_filter_options` returns the valid filter values so you don't have to guess (all filters are case-sensitive exact matches).
|
|
195
|
+
|
|
196
|
+
`evaluate_run` takes exactly one input: pass the save's contents as `run_json` (e.g. an uploaded/pasted `run.json`) **or** its location as `path` (e.g. a file in your Brotato save directory). The save is read-only — it is never modified. Ids the loaded dataset doesn't recognize (e.g. content newer than your build) are listed under `notes` rather than dropped; a malformed save returns `{"error": "bad_run_file", ...}`.
|
|
197
|
+
|
|
198
|
+
## Building the dataset
|
|
199
|
+
|
|
200
|
+
The dataset is **not committed** — it is built from an extraction of a real game install. The raw
|
|
201
|
+
`extracted/`, the decompiled `recovered/`, the copyrighted `game_files/`, and the derived
|
|
202
|
+
`data/brotato.json` are all gitignored (see [`docs/extraction-setup.md`](docs/extraction-setup.md)
|
|
203
|
+
for how the extraction is produced). Once `extracted/` is present at the repo root:
|
|
204
|
+
|
|
205
|
+
macOS / Linux (bash):
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
uv run python build_dataset.py \
|
|
209
|
+
--game-version 1.1.15.4 \
|
|
210
|
+
--generated-at $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Windows (PowerShell):
|
|
214
|
+
|
|
215
|
+
```powershell
|
|
216
|
+
uv run python build_dataset.py `
|
|
217
|
+
--game-version 1.1.15.4 `
|
|
218
|
+
--generated-at (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This writes `data/brotato.json`. Supply the actual installed Brotato version to `--game-version`
|
|
222
|
+
(it is not recorded inside the `.pck`; check the Steam client). Re-run after each patch to refresh
|
|
223
|
+
your local copy — it stays gitignored, so don't commit it.
|
|
224
|
+
|
|
225
|
+
## How it works
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
extracted/ (gitignored, regenerable) raw .tres game data
|
|
229
|
+
│
|
|
230
|
+
▼ build_dataset.py (offline, per patch)
|
|
231
|
+
data/brotato.json (gitignored, built locally) the deterministic core artifact
|
|
232
|
+
│
|
|
233
|
+
▼ loaded at startup
|
|
234
|
+
brotato_coach.server (FastMCP) 16 tools over the pure functions
|
|
235
|
+
│
|
|
236
|
+
▼ connected as a plugin
|
|
237
|
+
Claude Code / Desktop / Web chat frontend
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- `brotato_coach/tres.py` — a minimal Godot `.tres` parser.
|
|
241
|
+
- `brotato_coach/builders/` — turn parsed `.tres` into enriched records (weapons with precomputed DPS
|
|
242
|
+
lines, items with archetype flags, characters with gain modifiers, sets, and the verified
|
|
243
|
+
`stat_mechanics` table).
|
|
244
|
+
- `brotato_coach/calc.py` — pure DPS / merge math (no I/O), unit-tested against hand-verified values.
|
|
245
|
+
- `brotato_coach/{query,answers,evaluate}.py` — pure functions that produce finished answers.
|
|
246
|
+
- `brotato_coach/runfile.py` — pure parser that normalizes a Brotato `run.json` save into a build
|
|
247
|
+
(character, weapons, items, realized stats) for `evaluate_run`; the only I/O is reading the save file.
|
|
248
|
+
- `brotato_coach/server.py` — thin FastMCP wrappers over those functions.
|
|
249
|
+
|
|
250
|
+
Reference material on the game mechanics the coach encodes lives in [`docs/`](docs/)
|
|
251
|
+
(extraction setup, weapon-merge DPS methodology, run post-mortem methodology, stat mechanics).
|
|
252
|
+
|
|
253
|
+
## Disclaimer
|
|
254
|
+
|
|
255
|
+
This is an unofficial, fan-made tool. It is **not affiliated with, endorsed by, or sponsored by
|
|
256
|
+
Blobfish**, the developer of Brotato, or any of its partners. *Brotato* and all related names,
|
|
257
|
+
marks, and assets are the property of their respective owners.
|
|
258
|
+
|
|
259
|
+
This project ships **no game assets and no game data**. The stat dataset it operates on is
|
|
260
|
+
generated locally, by you, from a copy of the game you already own (`build_dataset.py` reads an
|
|
261
|
+
extraction of your own install). Nothing derived from the game's copyrighted files is distributed
|
|
262
|
+
in this repository.
|
|
263
|
+
|
|
264
|
+
The software is provided "as is", without warranty of any kind (see [LICENSE](LICENSE)). Its
|
|
265
|
+
recommendations are computed from datamined values and may be incomplete or wrong; use your own
|
|
266
|
+
judgment.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
[MIT](LICENSE) © 2026 Brendan LeFebvre. This license covers the code and documentation in this
|
|
271
|
+
repository only — it does not grant any rights to Brotato or its assets.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
|
|
5
|
+
from brotato_coach import calc, evaluate, query, runfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _weapon_at(ds: dict, name: str, tier: int) -> dict | None:
|
|
9
|
+
rec = query.get_weapon(ds, name, tier=tier)
|
|
10
|
+
return rec if "id" in rec else None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def weapon_dps(ds: dict, name: str, tier: int, stats: dict,
|
|
14
|
+
aoe_enemies_hit: float = 1.0) -> dict:
|
|
15
|
+
rec = query.get_weapon(ds, name, tier=tier)
|
|
16
|
+
if "id" not in rec:
|
|
17
|
+
return rec
|
|
18
|
+
rd = float(stats.get("ranged_damage", 0))
|
|
19
|
+
base = calc.dps_at(rec["dps_at_zero_rd"], rec["dps_slope_per_rd"], rd)
|
|
20
|
+
proc = aoe_enemies_hit * calc.dps_at(rec.get("proc_dps_at_zero_rd", 0.0),
|
|
21
|
+
rec.get("proc_dps_slope_per_rd", 0.0), rd)
|
|
22
|
+
return {
|
|
23
|
+
"name": rec["name"], "tier": tier, "ranged_damage": rd,
|
|
24
|
+
"dps": base + proc, "base_dps": base, "proc_dps": proc,
|
|
25
|
+
"unmodeled_effects": rec.get("unmodeled_effects", []),
|
|
26
|
+
"breakdown": {
|
|
27
|
+
"dps_at_zero_rd": rec["dps_at_zero_rd"],
|
|
28
|
+
"dps_slope_per_rd": rec["dps_slope_per_rd"],
|
|
29
|
+
"proc_dps_at_zero_rd": rec.get("proc_dps_at_zero_rd", 0.0),
|
|
30
|
+
"proc_dps_slope_per_rd": rec.get("proc_dps_slope_per_rd", 0.0),
|
|
31
|
+
"aoe_enemies_hit": aoe_enemies_hit,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compare_weapons(ds: dict, names_with_tiers: list, stats: dict,
|
|
37
|
+
aoe_enemies_hit: float = 1.0) -> dict:
|
|
38
|
+
rows = []
|
|
39
|
+
for name, tier in names_with_tiers:
|
|
40
|
+
r = weapon_dps(ds, name, tier, stats, aoe_enemies_hit)
|
|
41
|
+
if "dps" in r:
|
|
42
|
+
rows.append({"name": r["name"], "tier": tier, "dps": r["dps"],
|
|
43
|
+
"base_dps": r["base_dps"], "proc_dps": r["proc_dps"],
|
|
44
|
+
"unmodeled_effects": r["unmodeled_effects"]})
|
|
45
|
+
rows.sort(key=lambda x: x["dps"], reverse=True)
|
|
46
|
+
return {"ranking": rows}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compare_merge_paths(ds: dict, weapon_name: str, path_a: list, path_b: list,
|
|
50
|
+
rd_range: tuple = (0, 100)) -> dict:
|
|
51
|
+
def path_line(tiers: list) -> tuple[float, float] | None:
|
|
52
|
+
lines = []
|
|
53
|
+
for t in tiers:
|
|
54
|
+
rec = _weapon_at(ds, weapon_name, t)
|
|
55
|
+
if rec is None:
|
|
56
|
+
return None
|
|
57
|
+
lines.append((rec["dps_at_zero_rd"] + rec.get("proc_dps_at_zero_rd", 0.0),
|
|
58
|
+
rec["dps_slope_per_rd"] + rec.get("proc_dps_slope_per_rd", 0.0)))
|
|
59
|
+
return calc.sum_lines(lines)
|
|
60
|
+
|
|
61
|
+
line_a, line_b = path_line(path_a), path_line(path_b)
|
|
62
|
+
if line_a is None or line_b is None:
|
|
63
|
+
return {"error": "not_found",
|
|
64
|
+
"did_you_mean": query.suggest(ds["weapons"], weapon_name)}
|
|
65
|
+
|
|
66
|
+
result = calc.compare_lines(line_a, line_b, rd_range[0], rd_range[1])
|
|
67
|
+
return {
|
|
68
|
+
"weapon": weapon_name, "path_a": path_a, "path_b": path_b,
|
|
69
|
+
"line_a": line_a, "line_b": line_b, **result,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def explain_stat(ds: dict, stat: str) -> dict:
|
|
74
|
+
mechanics = ds.get("stat_mechanics", {})
|
|
75
|
+
if stat not in mechanics:
|
|
76
|
+
return {"error": "unknown_stat",
|
|
77
|
+
"did_you_mean": difflib.get_close_matches(stat, list(mechanics), n=3, cutoff=0.4)}
|
|
78
|
+
return {"stat": stat, **mechanics[stat]}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def stat_display_value(ds: dict, character: str, stat: str, raw_value: float) -> dict:
|
|
82
|
+
rec = query.get_character(ds, character)
|
|
83
|
+
modifier_pct = 0
|
|
84
|
+
if "id" in rec:
|
|
85
|
+
for mod in rec.get("gain_modifiers", []):
|
|
86
|
+
if mod["stat"] == stat:
|
|
87
|
+
modifier_pct = mod["pct"]
|
|
88
|
+
break
|
|
89
|
+
displayed = raw_value * (1 + modifier_pct / 100)
|
|
90
|
+
displayed = int(displayed) if float(displayed).is_integer() else displayed
|
|
91
|
+
return {"stat": stat, "raw_value": raw_value, "displayed_value": displayed,
|
|
92
|
+
"modifier_pct": modifier_pct}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def loadout_set_bonuses(ds: dict, weapon_names: list[str]) -> dict:
|
|
96
|
+
counts: dict[str, int] = {}
|
|
97
|
+
unknown: list[dict] = []
|
|
98
|
+
for name in weapon_names:
|
|
99
|
+
rec = query.get_weapon(ds, name)
|
|
100
|
+
if "matches" in rec:
|
|
101
|
+
rec = rec["matches"][0] # class membership is tier-independent
|
|
102
|
+
if "id" not in rec:
|
|
103
|
+
unknown.append({"name": name,
|
|
104
|
+
"did_you_mean": rec.get("did_you_mean", [])})
|
|
105
|
+
continue
|
|
106
|
+
for cls in rec.get("sets", []):
|
|
107
|
+
counts[cls] = counts.get(cls, 0) + 1 # duplicates count in-game
|
|
108
|
+
|
|
109
|
+
classes = []
|
|
110
|
+
for cls in sorted(counts):
|
|
111
|
+
n = counts[cls]
|
|
112
|
+
set_rec = query.get_set(ds, cls)
|
|
113
|
+
bonuses = set_rec.get("bonuses", []) if "id" in set_rec else []
|
|
114
|
+
active = [b for b in bonuses if b["count"] <= n]
|
|
115
|
+
upcoming = [b for b in bonuses if b["count"] > n]
|
|
116
|
+
nxt = None
|
|
117
|
+
if upcoming:
|
|
118
|
+
first = min(upcoming, key=lambda b: b["count"])
|
|
119
|
+
nxt = {**first, "needs": first["count"] - n}
|
|
120
|
+
classes.append({"class": cls, "count": n, "active": active, "next": nxt})
|
|
121
|
+
return {"classes": classes, "unknown_weapons": unknown}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def evaluate_run(ds: dict, run: dict) -> dict:
|
|
125
|
+
"""One-call run post-mortem: parse a Brotato save and evaluate the whole
|
|
126
|
+
build against the loaded dataset.
|
|
127
|
+
|
|
128
|
+
Returns run context, realized stats, a weapon-DPS ranking at those stats,
|
|
129
|
+
weapon-class set progress, and a per-item live/wasted/harmful verdict.
|
|
130
|
+
Unknown weapon/item ids (e.g. content newer than the dataset) are collected
|
|
131
|
+
in `notes` rather than dropped silently. A malformed save comes back as
|
|
132
|
+
`{"error": "bad_run_format", "detail": ...}`.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
build = runfile.parse_run(run)
|
|
136
|
+
except runfile.RunFormatError as exc:
|
|
137
|
+
return {"error": "bad_run_format", "detail": str(exc)}
|
|
138
|
+
|
|
139
|
+
stats = build["stats"]
|
|
140
|
+
notes: list[str] = []
|
|
141
|
+
|
|
142
|
+
char_rec = query.get_character(ds, build["character"])
|
|
143
|
+
char_name = char_rec["name"] if "id" in char_rec else build["character"]
|
|
144
|
+
if "id" not in char_rec:
|
|
145
|
+
notes.append(f"unknown character '{build['character']}' — "
|
|
146
|
+
"not in the loaded dataset")
|
|
147
|
+
|
|
148
|
+
ranking = compare_weapons(
|
|
149
|
+
ds, [(w["id"], w["tier"]) for w in build["weapons"]], stats)["ranking"]
|
|
150
|
+
|
|
151
|
+
weapon_ids = [w["id"] for w in build["weapons"]]
|
|
152
|
+
set_bonuses = loadout_set_bonuses(ds, weapon_ids)
|
|
153
|
+
for u in set_bonuses.get("unknown_weapons", []):
|
|
154
|
+
notes.append(f"unknown weapon '{u['name']}' — not in the loaded dataset")
|
|
155
|
+
|
|
156
|
+
item_verdicts = []
|
|
157
|
+
for item_id in build["items"]:
|
|
158
|
+
verdict = evaluate.evaluate_item_for_build(ds, item_id, build["character"], stats)
|
|
159
|
+
if "summary" in verdict:
|
|
160
|
+
item_verdicts.append(verdict)
|
|
161
|
+
else:
|
|
162
|
+
notes.append(f"unknown item '{item_id}' — not in the loaded dataset")
|
|
163
|
+
|
|
164
|
+
ctx = build["context"]
|
|
165
|
+
if ctx.get("coop"):
|
|
166
|
+
notes.append("co-op run — only player 1's build was analyzed")
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"run": {"character": char_name, "character_id": build["character"], **ctx},
|
|
170
|
+
"realized_stats": stats,
|
|
171
|
+
"weapons": build["weapons"],
|
|
172
|
+
"weapon_dps_ranking": ranking,
|
|
173
|
+
"set_bonuses": set_bonuses,
|
|
174
|
+
"item_verdicts": item_verdicts,
|
|
175
|
+
"notes": notes,
|
|
176
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from brotato_coach.builders.localization import resolve_text
|
|
4
|
+
from brotato_coach.tres import parse_tres
|
|
5
|
+
|
|
6
|
+
_GAIN_KEYS = {"effect_increase_stat_gains", "effect_reduce_stat_gains"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_character_record(data_text: str, effect_texts: list[str], *, char_id: str,
|
|
10
|
+
name: str, wanted_tags: list[str],
|
|
11
|
+
banned_item_groups: list[str],
|
|
12
|
+
tr: dict[str, str] | None = None) -> dict:
|
|
13
|
+
d = parse_tres(data_text).resource
|
|
14
|
+
flat_bonuses: list[dict] = []
|
|
15
|
+
gain_modifiers: list[dict] = []
|
|
16
|
+
special_effects: list[str] = []
|
|
17
|
+
|
|
18
|
+
for text in effect_texts:
|
|
19
|
+
r = parse_tres(text).resource
|
|
20
|
+
key = str(r.get("key", ""))
|
|
21
|
+
if key in _GAIN_KEYS:
|
|
22
|
+
mods = r.get("stats_modified", []) or []
|
|
23
|
+
stat = mods[0] if mods else None
|
|
24
|
+
if stat is not None:
|
|
25
|
+
gain_modifiers.append({"stat": stat, "pct": r.get("value", 0)})
|
|
26
|
+
elif key.startswith("stat_"):
|
|
27
|
+
flat_bonuses.append({"stat": key, "value": r.get("value", 0)})
|
|
28
|
+
else:
|
|
29
|
+
if not key.startswith("weapon_"):
|
|
30
|
+
special_effects.append(key)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"id": char_id,
|
|
34
|
+
"name": name,
|
|
35
|
+
"display_name": resolve_text(tr, d.get("name"), name),
|
|
36
|
+
"description": resolve_text(tr, d.get("description")),
|
|
37
|
+
"wanted_tags": wanted_tags,
|
|
38
|
+
"banned_item_groups": banned_item_groups,
|
|
39
|
+
"flat_bonuses": flat_bonuses,
|
|
40
|
+
"gain_modifiers": gain_modifiers,
|
|
41
|
+
"special_effects": special_effects,
|
|
42
|
+
}
|