diff --git a/.agents/skills/balatrobot/SKILL.md b/.agents/skills/balatrobot/SKILL.md new file mode 100644 index 00000000..406b1120 --- /dev/null +++ b/.agents/skills/balatrobot/SKILL.md @@ -0,0 +1,68 @@ +--- +name: balatrobot +description: Launch Balatro with the BalatroBot mod and interact via the CLI. Use when you need to manually test, reproduce issues, or inspect game state through the JSON-RPC API. +--- + +# BalatroBot CLI + +Four commands: `serve`, `api`, `list`, `stop`. Explore any with `--help`. + +## Workflow + +```bash +# Start server in background (ports are ephemeral, auto-allocated) +nohup balatrobot serve --render headless --settings turbo --debug > /tmp/bb.log 2>&1 & +sleep 5 +balatrobot api health # auto-discovers port via state file — no --host/--port + +# Call endpoints or replay a trace +balatrobot api gamestate +balatrobot api --requests path/to/trace.req.jsonl # --requests also auto-discovers + +balatrobot stop # always use stop, never kill/pkill +``` + +## `serve` + +```bash +balatrobot serve --render headless --settings turbo --debug +``` + +Key flags: `--render [headfull|headless|ondemand]` (default headfull), `--settings` (default "default"), `--debug`, `--num`. Blocks until Ctrl+C — background it with `&`/`nohup` before running other commands. + +## `api` + +```bash +balatrobot api [JSON_PARAMS] # params default {} +balatrobot api --requests PATH # replay JSONL trace +balatrobot api --requests PATH --responses PATH # verify against recorded +``` + +Reads the running instance from the state file — **never pass `--host`/`--port` manually** (ports are ephemeral). For multi-instance, use `-i`/`--index`. + +```bash +balatrobot api health +balatrobot api start '{"deck":"RED","stake":"WHITE"}' +balatrobot api play '{"cards":[0,1,2,3,4]}' +balatrobot api buy '{"pack": 0}' +balatrobot api gamestate | jq '.state' +``` + +See `docs/api.md` for methods, params, and state machine. + +## `list` + +```bash +balatrobot list # human-readable +balatrobot list --json | jq '.instances[0].port' # extract port/log_path +``` + +## `stop` + +```bash +balatrobot stop # SIGTERM + 5s poll, cleans state file +``` + +## Logs + +Session directory `logs//` contains `.log`, `.req.jsonl`, `.res.jsonl`. Find paths via `balatrobot list --json`. diff --git a/.agents/skills/git-commit/SKILL.md b/.agents/skills/git-commit/SKILL.md new file mode 100644 index 00000000..1f042172 --- /dev/null +++ b/.agents/skills/git-commit/SKILL.md @@ -0,0 +1,36 @@ +--- +name: git-commit +description: 'Conventional commit creator with auto-staging and message generation.' +license: MIT +allowed-tools: Bash +--- + +# Git Commit + +1. **Context:** `git log -n 5` to match repo style. + +2. **Review:** `git status` and `git diff`. + +3. **Stage & Group:** If many files changed, group them into **multiple logical commits** (`git add `). No secrets. + +4. **Message:** ALL commits should be multiline. (No `!` for breaking changes because we are still in early alpha). Format: + + ```text + (): + + <body> + ``` + + where `<title>` ≤ 72 characters (ideal ≤ 50) and `body` wrap at 72 characters. The best commit messages help someone six months later answer "Why was this change made?". + +5. **Commit:** Execute using heredoc: + + ```bash + git commit -F - <<'EOF' + <message here> + EOF + ``` + +6. **Iterate:** Repeat steps 3-5 until all logical groups are committed. + +7. **Safety:** No `--force`, `reset --hard`, config changes, or `--no-verify`. diff --git a/.agents/skills/triage-issue/REFERENCE.md b/.agents/skills/triage-issue/REFERENCE.md new file mode 100644 index 00000000..f66054bf --- /dev/null +++ b/.agents/skills/triage-issue/REFERENCE.md @@ -0,0 +1,61 @@ +# HTML Report Reference + +## Palette (Tokyo Night) + +Include this `<style>` block in the report. It provides a dark theme with semantic color variables. Compose the rest of the report freely — choose whatever sections, layout, and HTML elements best explain the issue. + +```css +:root { + --bg: #1a1b26; --surface: #24283b; --border: #3b4261; + --text: #c0caf5; --muted: #565f89; --accent: #7aa2f7; + --green: #9ece6a; --red: #f7768e; --yellow: #e0af68; --purple: #bb9af7; +} +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); color: var(--text); line-height: 1.65; + padding: 2.5rem; max-width: 860px; margin: 0 auto; font-size: 0.9rem; +} +h2 { color: var(--accent); margin: 2rem 0 0.5rem; } +code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 0.1rem 0.35rem; } +pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; overflow-x: auto; font-size: 0.82rem; } +a { color: var(--accent); } +hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; } +``` + +## Verdict badges + +```css +.badge { display: inline-block; padding: 0.2rem 0.65rem; border-radius: 20px; font-size: 0.78rem; font-weight: 600; } +.badge-green { background: rgba(158,206,106,0.15); color: var(--green); border: 1px solid rgba(158,206,106,0.3); } +.badge-red { background: rgba(247,118,142,0.15); color: var(--red); border: 1px solid rgba(247,118,142,0.3); } +.badge-yellow { background: rgba(224,175,104,0.15); color: var(--yellow); border: 1px solid rgba(224,175,104,0.3); } +``` + +| Verdict | Class | Color | +|---------|-------|-------| +| Reproduced / bug confirmed | `badge-red` | `--red` | +| Already fixed / no action needed | `badge-green` | `--green` | +| Needs manual review / inconclusive | `badge-yellow` | `--yellow` | + +## Required header + +Every report must start with: + +```html +<h1>{issue title}</h1> +<p class="meta"> + <code>{scope}</code> · + <a href="https://github.com/coder/balatrobot/issues/{NNN}">#{NNN}</a> · + coder/balatrobot · + <span class="badge badge-{color}">{VERDICT}</span> +</p> +``` + +The link to the original issue on GitHub is mandatory. + +## Guidelines + +- **Compose freely.** The LLM decides which sections (`<h2>`) to include based on what best explains the issue. There is no fixed template beyond the header. +- **Use the palette variables** for all colors. Use `--red`/`--green` for diff highlights, `--accent` for headings and links, `--muted` for secondary text. +- **Keep it simple.** Plain HTML. No frameworks, no JavaScript. The report is a static file opened in a browser. +- **Save to** `/tmp/balatrobot/issues/{NNN}/report.html` diff --git a/.agents/skills/triage-issue/SKILL.md b/.agents/skills/triage-issue/SKILL.md new file mode 100644 index 00000000..1b062251 --- /dev/null +++ b/.agents/skills/triage-issue/SKILL.md @@ -0,0 +1,77 @@ +--- +name: triage-issue +description: Triage and reproduce GitHub issues for the coder/balatrobot repo. Fetches issue data, downloads attachments, creates a branch, reproduces the bug, and generates an HTML investigation report. Use when the user says "/triage-issue NNN" or asks to reproduce a specific issue by number. +--- + +# Triage Issue + +Reproduce and investigate a GitHub issue end-to-end, producing an HTML report. + +## Quick start + +User provides a GitHub issue URL. Extract the issue number and run the full workflow below. + +## Workflow + +### 1. Fetch & download + +```bash +mkdir -p /tmp/balatrobot/issues/NNN +gh issue view NNN --repo coder/balatrobot --json title,body,comments,labels,state \ + | tee /tmp/balatrobot/issues/NNN/issue.json +``` + +Extract attachment URLs from body + comments and download: +```bash +gh issue view NNN --repo coder/balatrobot --json body,comments -q '.body, (.comments[].body)' \ + | grep -oE 'https://github.com/user-attachments/files/[^ )]+' \ + | xargs -I{} curl -sLo /tmp/balatrobot/issues/NNN/$(basename {}) '{}' +``` + +### 2. Read before executing + +**Before running anything**, read all downloaded files: +- **`script.py`** — reproduction script. Read fully before running. +- **`*.req.jsonl`** — replayable via `balatrobot api --requests`. +- **`*.res.jsonl`** — response log for comparison. +- **`*.log`** — Balatro log output for error context. + +Choose reproduction method: `script.py` if present (adapt port), else replay `.req.jsonl`. + +### 3. Create branch + +Derive prefix from issue title (`fix(...)` → `fix/`, `feat(...)` → `feat/`, etc., fallback `fix/`): +```bash +git checkout <current-active-branch> +git checkout -b <prefix>/issue-NNN +``` + +### 4. Reproduce + +```bash +balatrobot serve --render headless --settings turbo --debug +``` + +Wait for ready, then reproduce using the chosen method. Run **≥3 times** to confirm consistency. + +### 5. Report & cleanup + +Generate HTML report at `/tmp/balatrobot/issues/NNN/report.html`. See [REFERENCE.md](REFERENCE.md) for template. Must include: issue title, verdict badge (`REPRODUCED`/`ALREADY FIXED`/`NEEDS MANUAL REVIEW`), summary, reproduction steps, results table, analysis, conclusion. + +Then: +- `balatrobot stop` +- If already fixed → delete branch, switch back. +- If reproducible → keep branch. +- `open /tmp/balatrobot/issues/NNN/report.html` + +## Checklist + +Before reporting done, verify: +- [ ] Issue data fetched and saved to `/tmp/balatrobot/issues/NNN/` +- [ ] All attachments downloaded +- [ ] Attachments read and understood before execution +- [ ] Branch created from active branch +- [ ] Issue reproduced (or confirmed already fixed) with ≥3 runs +- [ ] HTML report generated at `/tmp/balatrobot/issues/NNN/report.html` +- [ ] Report opened in browser +- [ ] Server stopped, branch cleaned up if no fix needed diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 34b95fa0..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(pytest:*)", - "Bash(make:*)" - ], - "deny": [ - "Edit(CHANGELOG.md)", - "Write(CHANGELOG.md)" - ] - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "make quality", - "timeout": 5 - } - ] - } - ] - } -} diff --git a/.claude/skills/balatrobot/SKILL.md b/.claude/skills/balatrobot/SKILL.md deleted file mode 100644 index 2ff66237..00000000 --- a/.claude/skills/balatrobot/SKILL.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -name: balatrobot -description: Run and debug BalatroBot locally. Use when you need to start Balatro with the BalatroBot Lua mod, manually test or reproduce issues via the JSON-RPC HTTP API, inspect the newest session logs under logs/, and capture screenshots into logs/<session>/artifacts/ using only the balatrobot CLI (no curl, no uvx). -allowed-tools: Bash(balatrobot:*) Bash(mkdir:*) Bash(ls:*) Bash(tail:*) Bash(PORT=:*) Bash(echo:*) Bash(jq:*) Bash(sleep:*) Read Grep -disable-model-invocation: true ---- - -# BalatroBot debug/runbook - -## Ground rules - -- Run commands from the repo root. -- Use `balatrobot ...` only (no `uvx`, no `curl`). -- Run `balatrobot api ...` requests sequentially (avoid concurrent calls). -- Prefer minimal, targeted changes while debugging; avoid large refactors. - -## Start BalatroBot - -### Choose a port (recommended) - -Pick a random port in the range `20000-30000` to avoid the port staying busy for ~20-30s after restarting: - -```bash -PORT="$((20000 + RANDOM % 10001))" -echo "Using PORT=$PORT" -``` - -Pick a new random `PORT` every time you restart `balatrobot serve` (crash/restart/kill/start). - -### Default profile (most cases) - -Use headless mode unless explicitly asked to take screenshots: - -```bash -balatrobot serve --port "$PORT" --headless --fast --debug -``` - -### Screenshot profile (only when explicitly asked) - -Use render-on-API mode for deterministic screenshots: - -```bash -balatrobot serve --port "$PORT" --render-on-api --fast --debug -``` - -## Call the API (no curl) - -Run API calls in a second terminal while `balatrobot serve ...` is running. - -Reminder: wrap JSON params in single quotes so you don't need to escape JSON double quotes. - -```bash -# Health check -balatrobot api health --port "$PORT" - -# Get full game state -balatrobot api gamestate --port "$PORT" - -# Return to menu -balatrobot api menu --port "$PORT" - -# Start a new run -balatrobot api start '{"deck":"RED","stake":"WHITE"}' --port "$PORT" - -# Select blind -balatrobot api select --port "$PORT" - -# Play cards (0-based indices) -balatrobot api play '{"cards":[0,1,2,3,4]}' --port "$PORT" -``` - -### Filter responses with `jq` (optional) - -The `balatrobot api ...` CLI prints the JSON-RPC `result` object to stdout on success (see `docs/api.md`), so `jq` filters target top-level fields like `.state` (not `.result.state`). - -```bash -# Print the current state as a raw string (MENU / BLIND_SELECT / SELECTING_HAND / ...) -balatrobot api gamestate --port "$PORT" | jq -r '.state' - -# Quick summary (useful for bug reports) -balatrobot api gamestate --port "$PORT" | jq '{state, round_num, ante_num, money, deck, stake, won}' - -# Round counters (hands/discards/chips) -balatrobot api gamestate --port "$PORT" | jq '.round | {hands_left, discards_left, chips, reroll_cost}' - -# Cards currently in hand (ids + labels) -balatrobot api gamestate --port "$PORT" | jq '.hand.cards | map({id, key, label})' - -# Joker labels (one per line) -balatrobot api gamestate --port "$PORT" | jq -r '.jokers.cards[].label' -``` - -On failure, `balatrobot api ...` prints a human-readable error to stderr and exits non-zero. To extract fields from that error output, capture stderr and use `jq` raw mode: - -```bash -balatrobot api play '{"cards":[999]}' --port "$PORT" 2>&1 >/dev/null \ - | jq -R 'capture("^Error: (?<name>.*?) - (?<message>.*)$")' -``` - -Always pass the same `--port` to both `serve` and `api`. - -## Logs and sessions - -### Session layout - -Each `balatrobot serve ...` run creates: - -- `logs/<session>/<port>.log` (game + mod stdout/stderr) - -`<session>` is a timestamp folder like `2025-12-29T19-18-18` (lexicographically sortable). - -### Find the current session - -Pick the newest session directory (works because of the timestamp format): - -```bash -SESSION="$(ls -1 logs | sort | tail -n 1)" -``` - -Use it to open/tail the log: - -```bash -tail -f "logs/$SESSION/$PORT.log" -``` - -The log filename matches the port: `logs/<session>/<port>.log`. - -## Screenshots (write to logs/<session>/artifacts/) - -Only do this when explicitly asked for a screenshot. - -1. Start the server with the screenshot profile: - - ```bash - balatrobot serve --port "$PORT" --render-on-api --fast --debug - ``` - -2. Create the artifacts directory under the newest session: - - ```bash - SESSION="$(ls -1 logs | sort | tail -n 1)" - mkdir -p "logs/$SESSION/artifacts" - ``` - -3. Call the screenshot endpoint with an absolute path inside that folder: - - ```bash - balatrobot api screenshot "{\"path\":\"$(pwd)/logs/$SESSION/artifacts/screenshot.png\"}" --port "$PORT" - ``` - -## Debug workflow (tight loop) - -1. Reproduce with the smallest possible sequence of `balatrobot api ...` calls. -2. Capture: - - the exact `balatrobot serve ...` command, - - host/port, - - the session folder name, - - relevant excerpts from `logs/<session>/<port>.log`, - - the JSON outputs (stdout) and errors (stderr) from `balatrobot api ...`. -3. Read the most relevant code before changing anything. -4. If needed, add minimal logging close to the suspected behavior, then re-run. - -## Where to look in the repo - -- CLI entry points: `src/balatrobot/cli/serve.py`, `src/balatrobot/cli/api.py` -- Game process + log session creation: `src/balatrobot/manager.py` -- Lua HTTP server + dispatcher: `src/lua/core/server.lua`, `src/lua/core/dispatcher.lua` -- API reference/spec: `docs/api.md`, `src/lua/utils/openrpc.json` -- Endpoint implementations: `src/lua/endpoints/*.lua` diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index fe399531..b6332fdd 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -48,7 +48,7 @@ jobs: - name: Check markdown formatting run: | source .venv/bin/activate - mdformat --check . + mdformat --check --number --exclude "CHANGELOG.md" --exclude ".venv/**" --exclude "vendors/**" ./docs README.md .agents/skills/balatrobot/SKILL.md - name: Run ty run: | source .venv/bin/activate diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index a5c0110c..1823f95f 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -1,11 +1,10 @@ name: Deploy Documentation on: push: - branches: [main] + branches: [main, dev] + tags: ['v*'] permissions: contents: write - pages: write - id-token: write concurrency: group: "pages" cancel-in-progress: false @@ -14,15 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version-file: ".python-version" - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -32,6 +31,7 @@ jobs: run: | uv venv uv sync --dev + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - name: Initialize cache uses: actions/cache@v4 with: @@ -39,7 +39,15 @@ jobs: path: .cache restore-keys: | mkdocs-material- - - name: Deploy documentation + - name: Deploy run: | source .venv/bin/activate - mkdocs gh-deploy --force + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + VER="${GITHUB_REF#refs/tags/v}" + mike deploy --push "$VER" + elif [[ "$GITHUB_REF" == refs/heads/main ]]; then + mike deploy --push --update-aliases latest + mike set-default --push latest + else + mike deploy --push dev + fi diff --git a/.gitignore b/.gitignore index b291817f..6f5705b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,333 +1,47 @@ ################################################################################ -# MacOS +# macOS ################################################################################ -# General .DS_Store -__MACOSX/ -.AppleDouble -.LSOverride -Icon[ ] - -# Thumbnails ._* -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - ################################################################################ # Python ################################################################################ -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] +*.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +site/ *.egg-info/ -.installed.cfg *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments +.venv/ .env .envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy +.pytest_cache/ .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: .ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml +.coverage ################################################################################ # Lua ################################################################################ -# Lua Language Server .luarc.json -# Compiled Lua sources -luac.out - -# luarocks build files -*.src.rock -*.zip -*.tar.gz - -# Object files -*.o -*.os -*.ko -*.obj -*.elf - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo -*.def -*.exp - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - ################################################################################ -# Other files +# Project ################################################################################ -# smods -smods -smods.wiki - -# lovely dump -dump - -# balatro -balatro +vendors/ *.jkr -# balatrobot runs/*.jsonl - -# logs logs/ - -################################################################################ -# Legacy files -################################################################################ - -src/lua_old -src/lua_oldish - -tests/lua_old -balatrobot_old.lua -balatrobot_oldish.lua - -balatro_oldish.sh -balatro.sh +.antigravitycli +.agents/skills/review diff --git a/.mdformat.toml b/.mdformat.toml deleted file mode 100644 index 63865ff6..00000000 --- a/.mdformat.toml +++ /dev/null @@ -1,5 +0,0 @@ -wrap = "keep" -number = true -end_of_line = "lf" -validate = true -exclude = ["balatro/**", "CHANGELOG.md", ".venv/**", "smods.wiki/**"] diff --git a/.mux/init b/.mux/init deleted file mode 100755 index c28bf01c..00000000 --- a/.mux/init +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "Runtime: $MUX_RUNTIME" -echo "Project path: $MUX_PROJECT_PATH" -echo "Workspace: $PWD" - -if [ "$MUX_RUNTIME" = "ssh" ]; then - echo "SSH runtime not supported" - exit 1 -fi - -if [ "$MUX_RUNTIME" != "local" ]; then - # Copy .envrc from project root - if [ -f "../.envrc" ]; then - echo "Copying .envrc from parent..." - cp "../.envrc" ".envrc" - elif [ -n "$MUX_PROJECT_PATH" ] && [ -f "$MUX_PROJECT_PATH/.envrc" ]; then - echo "Copying .envrc from project root..." - cp "$MUX_PROJECT_PATH/.envrc" ".envrc" - fi - - # Copy .luarc.json for Lua language server - if [ -f "../.luarc.json" ]; then - echo "Copying .luarc.json from parent..." - cp "../.luarc.json" ".luarc.json" - elif [ -n "$MUX_PROJECT_PATH" ] && [ -f "$MUX_PROJECT_PATH/.luarc.json" ]; then - echo "Copying .luarc.json from project root..." - cp "$MUX_PROJECT_PATH/.luarc.json" ".luarc.json" - fi -else - echo "Local mode: using existing config files" -fi - -echo "Setting up Python environment..." -uv sync --group dev --group test - -if [ -f ".envrc" ]; then - echo "Sourcing .envrc..." - source .envrc -fi - -echo "Init complete!" diff --git a/.mux/mcp.jsonc b/.mux/mcp.jsonc deleted file mode 100644 index 46c666f5..00000000 --- a/.mux/mcp.jsonc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "servers": { - "context7": { - "transport": "http", - "url": "https://mcp.context7.com/mcp", - "headers": { - "CONTEXT7_API_KEY": { - "secret": "CONTEXT7_API_KEY" - } - } - } - } -} diff --git a/.mux/tool_env b/.mux/tool_env deleted file mode 100644 index 3f5a6134..00000000 --- a/.mux/tool_env +++ /dev/null @@ -1,5 +0,0 @@ -# Sourced before every bash tool call -# Activate venv and load environment variables - -source .venv/bin/activate 2>/dev/null || true -source .envrc 2>/dev/null || true diff --git a/.mux/tool_post b/.mux/tool_post deleted file mode 100755 index aee67383..00000000 --- a/.mux/tool_post +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Runs after every tool execution - -# Run quality checks after Python or Lua file edits -if [[ "$MUX_TOOL" == file_edit_* ]]; then - file=$(echo "$MUX_TOOL_INPUT" | jq -r '.file_path') - - if [[ "$file" == *.py ]] || [[ "$file" == *.lua ]]; then - echo "Running quality checks..." >&2 - make quality 2>&1 || exit 1 - fi -fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4502d022 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +Project instructions for coding agents working on BalatroBot. + +## What is this + +BalatroBot is a [Balatro](https://www.playbalatro.com/) mod that exposes a JSON-RPC 2.0 HTTP API for external bot control. Two codebases: + +- **CLI** (`src/balatrobot/`) — Python package. Launches and manages the Balatro process. +- **Mod** (`src/lua/`) — Lua code injected into Balatro via Lovely/SMODS. Serves the API. + +## Project structure + +``` +src/balatrobot/ # Python CLI + game process manager +src/lua/core/ # HTTP server, dispatcher, validator +src/lua/endpoints/ # API endpoint modules (one file per endpoint) +src/lua/utils/ # Types, enums, errors, gamestate extraction, OpenRPC spec +tests/cli/ # Tests for the Python package +tests/lua/ # Tests for the Lua API (start real Balatro instances) +docs/ # Public documentation (api.md, cli.md, contributing.md) +``` + +## Rules + +- Use `make` commands for all tooling (`make help` to list them). Do not run bare `ruff`, `ty`, `mdformat`, etc. +- `tests/cli` and `tests/lua` must run separately. Never `pytest tests`. +- `CHANGELOG.md` is auto-generated by release-please. Do not edit it manually. +- See `CONTEXT.md` for project terminology. + +## Further reading + +- `CONTEXT.md` — glossary and domain terms +- `docs/api.md` — full API reference +- `docs/contributing.md` — dev environment setup, PR guidelines +- `src/lua/utils/openrpc.json` — machine-readable API spec +- `src/lua/utils/types.lua` — type definitions and endpoint schemas +- `src/lua/utils/enums.lua` — all enum values (states, card types, etc.) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 85f7cbd8..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,171 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -**GitHub Repository**: [`coder/balatrobot`](https://github.com/coder/balatrobot) - -## Overview - -BalatroBot is a framework for Balatro bot development. It consists of two main parts: - -1. **Python Package** (`src/balatrobot/`): A CLI and library to manage the Balatro game process, inject the mod, and handle communication. -2. **Lua API** (`src/lua/`): The mod code running inside Balatro (Love2D) that exposes a HTTP JSON-RPC 2.0 API. - -### Testing - -Integration tests (`tests/lua`) automatically start and stop Balatro instances on random ports. - -```bash -# Run all tests (CLI and Lua suites) -make test - -# Run Lua integration tests in parallel -pytest -n 6 tests/lua - -# Run CLI tests -pytest tests/cli - -# Run specific tests -pytest tests/lua/endpoints/test_health.py -v -pytest tests/lua/endpoints/test_health.py::TestHealthEndpoint::test_health_from_MENU -v - -# Run only integration tests -pytest tests/cli -m integration - -# Run non-integration tests (no Balatro instance required) -pytest tests/cli -m "not integration" - -# Manual launch for dev/debugging -balatrobot --fast --debug -``` - -### Make Commands - -Available make targets: - -| Target | Description | -| ---------------- | ------------------------------------------------------- | -| `make help` | Show all available targets | -| `make lint` | Run ruff linter (check only) | -| `make format` | Run ruff and mdformat formatters | -| `make typecheck` | Run type checker (Python and Lua) | -| `make quality` | Run all code quality checks (lint + typecheck + format) | -| `make test` | Run all tests | -| `make all` | Run all quality checks and tests | -| `make fixtures` | Generate test fixtures | -| `make install` | Install dependencies | - -**Important rules:** - -1. **Only run make commands when explicitly asked.** Do not proactively run `make test`, `make quality`, etc. -2. **Never run bare linting/formatting/typechecking tools.** Always use make targets instead: - - Use `make lint` instead of `ruff check` - - Use `make format` instead of `ruff format` - - Use `make typecheck` instead of `ty check` - - Use `make quality` for all checks combined - -## Architecture - -### 1. Python Layer (`src/balatrobot/`) - -Controls the game lifecycle and provides the CLI. - -- **CLI** (`cli.py`): Entry point (`balatrobot`). Handles arguments like `--fast`, `--debug`, `--headless`. -- **Manager** (`manager.py`): `BalatroInstance` context manager. Starts the game process, handles logging, and waits for the API to be healthy. -- **Config** (`config.py`): Configuration management using `dataclasses` and environment variables. -- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, Linux (Proton), and native Love2D. - -### 2. Lua Layer (`src/lua/`) - -Runs inside the game engine and exposes an API. - -- **HTTP Server** (`src/lua/core/server.lua`) - - - Single-client HTTP/1.1 server on port 12346 (default) - - **Protocol**: JSON-RPC 2.0 over HTTP POST to `/` - - **Request**: `{"jsonrpc": "2.0", "method": "endpoint", "params": {...}, "id": 1}` - - **Response**: `{"jsonrpc": "2.0", "result": {...}, "id": 1}` - - Max body size: 64KB - -- **Dispatcher** (`src/lua/core/dispatcher.lua`) - - - Routes requests based on the `method` field. - - Validates: - 1. Protocol (JSON-RPC 2.0, valid ID) - 2. Schema (via `validator.lua`) - 3. Game State (`requires_state`) - 4. Endpoint execution - -- **Endpoints** (`src/lua/endpoints/*.lua`) - - - Stateless modules defining `schema` and `execute` functions. - - 0-based indexing in API vs 1-based in Lua. - - OpenRPC Specification (`src/lua/utils/openrpc.json`): Machine-readable API documentation describing all endpoints. - - **Core Endpoints:** - - - `add.lua`: Add a new card (joker, consumable, voucher, playing card, or booster pack). - - `buy.lua`: Buy a card or booster pack from the shop. - - `cash_out.lua`: Cash out and collect round rewards. - - `discard.lua`: Discard cards from the hand. - - `gamestate.lua`: Get current game state. - - `health.lua`: Health check endpoint for connection testing. - - `load.lua`: Load a saved run state from a file. - - `menu.lua`: Return to the main menu from any game state. - - `next_round.lua`: Leave the shop and advance to blind selection. - - `pack.lua`: Select or skip a card from an opened booster pack. - - `play.lua`: Play a card from the hand. - - `rearrange.lua`: Rearrange cards in hand, jokers, or consumables. - - `reroll.lua`: Reroll to update the cards in the shop area. - - `save.lua`: Save the current run state to a file. - - `screenshot.lua`: Take a screenshot of the current game state. - - `select.lua`: Select the current blind. - - `sell.lua`: Sell a joker or consumable from player inventory. - - `set.lua`: Set a in-game value (money, chips, ante, etc.). - - `skip.lua`: Skip the current blind (Small or Big only). - - `start.lua`: Start a new game run with specified deck and stake. - - `use.lua`: Use a consumable card with optional target cards. - - **Test Endpoints (`src/lua/endpoints/tests/*.lua`):** - - - `echo.lua`: Test endpoint for dispatcher testing. - - `endpoint.lua`: Test endpoint with schema for dispatcher testing. - - `error.lua`: Test endpoint that throws runtime errors. - - `state.lua`: Test endpoint that requires specific game states. - - `validation.lua`: Comprehensive validation test endpoint. - -## Key Files - -- **Python**: - - `src/balatrobot/cli.py`: Main entry point. - - `src/balatrobot/manager.py`: Game process logic. -- **Lua**: - - `balatrobot.lua`: Mod entry point. - - `src/lua/core/server.lua`: HTTP/TCP handling. - - `src/lua/endpoints/`: All API commands. -- **Configuration**: - - `pyproject.toml`: Python dependencies and tools config. - - `balatrobot.json` / `balatrobot.lua`: SMODS mod metadata. - -### Error Handling - -Error codes are mapped to JSON-RPC standard and custom ranges: - -- `INTERNAL_ERROR` (-32000): Runtime errors -- `BAD_REQUEST` (-32001): Invalid schema or parameters -- `INVALID_STATE` (-32002): Action not allowed in current game state -- `NOT_ALLOWED` (-32003): Action prevented by game rules - -Error responses follow JSON-RPC 2.0 format: - -```json -{ - "jsonrpc": "2.0", - "error": { - "code": -32001, - "message": "Human readable error", - "data": { "name": "BAD_REQUEST" } - }, - "id": 1 -} -``` diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..90251987 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,34 @@ +# CONTEXT.md + +Glossary of terms used in the BalatroBot project. + +| Term | Meaning | +|------|---------| +| **Balatro** | The commercial game by LocalThunk. Runs on Love2D. | +| **BalatroBot** | This project. A framework for Balatro bot development. | +| **CLI** (`src/balatrobot/`) | The Python package. Manages the Balatro process and provides the `balatrobot` command. | +| **Lua** or **mod** (`src/lua/`) | The SMODS mod injected into Balatro via Lovely. Serves a JSON-RPC 2.0 HTTP API. | +| **SMODS** | Balatro modding framework. Provides the API for creating mods (jokers, consumables, etc.). [github.com/Steamodded/smods](https://github.com/Steamodded/smods) | +| **Lovely** | Injection layer that loads mods into Balatro's Love2D process. [github.com/ethangreen-dev/lovely-injector](https://github.com/ethangreen-dev/lovely-injector) | +| **DebugPlus** | Optional SMODS mod for debug logging and UI. Required for the `--debug` flag. [github.com/WilsontheWolf/DebugPlus](https://github.com/WilsontheWolf/DebugPlus) | +| **OpenRPC spec** | Machine-readable API specification in `src/lua/utils/openrpc.json`. Describes all endpoints, params, and responses. [open-rpc.org](https://open-rpc.org/) | +| **game state** / **state** / **gamestate** | Ambiguous by design — meaning is clear from context. Can refer to: (1) the current game phase (e.g. `BLIND_SELECT`, `SHOP`), or (2) the JSON object returned by the API endpoints (a full snapshot of the game - see ./src/lua/utils/types.lua for fields). | +| **run** | A full game from start to win or loss. Progresses through antes (1–8 by default). | +| **ante** | A set of 3 rounds within a run: small blind, big blind, boss blind. Runs go from ante 1 to ante 8. | +| **round** | A single blind within a run. Each ante has 3 rounds. | +| **blind** | Overloaded by design — clear from context. Can refer to: (1) the game phase `BLIND_SELECT` where you choose a blind, (2) the specific blind type (small/big/boss), or (3) the blind entity with its name, effect, and score requirement. | +| **playing card** | A standard 52-card deck card (suit + rank). Can hold modifiers: seal, edition, enhancement. | +| **joker** | A modifier card sitting in the joker area. Provides ongoing scoring effects. | +| **consumable** | A one-time-use card: tarot, planet, or spectral. **Note:** Balatro's source code misspells this as `consumeables` — our API uses the correct spelling `consumables`. | +| **voucher** | A permanent upgrade purchased in the shop. Persists for the entire run. | +| **hand** | The cards currently dealt to the player (up to 8). An `Area` in the gamestate response. | +| **hands** | Poker hand information dictionary (pair, flush, straight, etc.). Tracks level, chips, mult, times played. | +| **area** | A card container in the gamestate (jokers, consumables, hand, cards, pack, shop, vouchers, packs). Each has `count`, `limit`, and `cards`. | +| **pack** / **booster** / **booster pack** | Same thing. A purchasable pack of cards you open and choose from. | +| **test fixture** | A JSON file of API call sequences that reproduces a specific game state. Not a pytest fixture. Generated by `make fixtures` and loaded by tests. Fixture keys follow `<state>--<key1>-<value1>[--<key2>-<value2>...]` where keys use dotted paths for nested fields (e.g. `shop.cards[0].set`) and spaces in values are replaced with `+`. | +| **`dev` marker** | `@pytest.mark.dev` — tags tests currently being developed. Run with `pytest -m dev` to isolate. Remove when done. Ephemeral, not permanent. | +| **save profile** | Balatro's numbered in-game save slot (1–3), identified by a `name` field (e.g. `BalatroBot`). Loaded from `.jkr` at boot into `G.PROFILES[n]`. What the activation gate reads. _Avoid_: in-game profile, save slot. | +| **runtime profile** | A named directory under `src/lua/profiles/` (e.g. `fast`, `turbo`) whose `settings.lua`/`profile.lua` are deep-merged into `G.SETTINGS`/`G.PROFILES` after the gate passes. Selected via `--settings` / `BALATROBOT_SETTINGS`. _Avoid_: settings profile (that term belongs to `S1M0N38/balatrosettings`), preset. | +| **BalatroBot profile** | A save profile named exactly `BalatroBot`. Required for the mod to activate — if no save profile with this name exists, the HTTP server does not start and no runtime profile is applied. | +| **render mode** | How BalatroBot handles rendering: `headfull` (normal rendering, default), `headless` (no rendering, no window), or `ondemand` (render only when triggered by an API call). Set via `--render` or `BALATROBOT_RENDER`. | +| **endpoint** | A single API operation (e.g. `play`, `start`, `health`). Each endpoint is a Lua module in `src/lua/endpoints/`. Called "method" in JSON-RPC contexts and exposed as `balatrobot api <endpoint>` in the CLI. | diff --git a/Makefile b/Makefile index c916b068..19b9eb20 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ format: ## Run formatters (ruff, mdformat, stylua) ruff check --select I --fix . ruff format . @$(PRINT) "$(YELLOW)Running mdformat formatter...$(RESET)" - mdformat ./docs README.md CLAUDE.md .claude/skills/balatrobot/SKILL.md + mdformat --number --exclude "CHANGELOG.md" --exclude ".venv/**" --exclude "vendors/**" ./docs README.md .agents/skills/balatrobot/SKILL.md @if command -v stylua >/dev/null 2>&1; then \ $(PRINT) "$(YELLOW)Running stylua formatter...$(RESET)"; \ stylua src/lua; \ @@ -58,7 +58,7 @@ typecheck: ## Run type checkers (Python and Lua) @ty check @if command -v lua-language-server >/dev/null 2>&1 && [ -f .luarc.json ]; then \ $(PRINT) "$(YELLOW)Running Lua type checker...$(RESET)"; \ - lua-language-server --check balatrobot.lua src/lua \ + lua-language-server --check="$(CURDIR)" \ --configpath="$(CURDIR)/.luarc.json" 2>/dev/null; \ else \ $(PRINT) "$(BLUE)Skipping Lua type checker (lua-language-server not found or .luarc.json missing)$(RESET)"; \ @@ -68,9 +68,6 @@ quality: lint typecheck format ## Run all code quality checks @$(PRINT) "$(GREEN)✓ All checks completed$(RESET)" fixtures: ## Generate fixtures - @$(PRINT) "$(YELLOW)Checking Balatro is running...$(RESET)" - @balatrobot api health || (echo ''; echo ' Start Balatro in another terminal:'; echo ' balatrobot serve --fast --debug'; echo ''; exit 1) - @$(PRINT) "$(GREEN) Connected!$(RESET)" @$(PRINT) "$(YELLOW)Generating all fixtures...$(RESET)" python tests/fixtures/generate.py diff --git a/balatrobot.lua b/balatrobot.lua index 90b3a54f..e09ccece 100644 --- a/balatrobot.lua +++ b/balatrobot.lua @@ -2,7 +2,9 @@ assert(SMODS.load_file("src/lua/settings.lua"))() -- define BB_SETTINGS -- Configure Balatro with appropriate settings from environment variables -BB_SETTINGS.setup() +if not BB_SETTINGS.setup() then + return +end -- Endpoints for the BalatroBot API BB_ENDPOINTS = { diff --git a/docs/adr/0001-settings-redesign.md b/docs/adr/0001-settings-redesign.md new file mode 100644 index 00000000..ccaa2c3b --- /dev/null +++ b/docs/adr/0001-settings-redesign.md @@ -0,0 +1,40 @@ +# Settings profiles bundled in the mod tree + +BalatroBot uses bundled **settings profiles** to configure Balatro's game settings (speed, graphics, audio, window, etc.). Profiles live inside the Lua mod tree at `src/lua/profiles/` and are selected by bare name via `--settings fast` or `BALATROBOT_SETTINGS=fast`. A `default` profile is always applied when no profile is specified. + +**Considered Options:** + +- **Individual flags** (v1 approach): flexible and composable, but the surface grew to 19 flags. Most were thin wrappers around `G.SETTINGS` fields that Balatro already has a mechanism for. Maintenance burden grew with every new setting. +- **External profile directory** (v2 initial approach): pointed `--settings` to an external `balatrosettings` repo checkout. Worked but required users to clone and maintain a separate repo, used absolute paths on the CLI, and introduced platform-specific path issues. +- **Bundled profiles with name-based resolution** (chosen): profiles are directories inside `src/lua/profiles/` with `settings.lua` (required) and `profile.lua` (optional). The CLI accepts bare names; the Lua mod resolves paths via `SMODS.current_mod.path`. Simpler UX, no external dependencies, portable across platforms. +- **Sidecar Lua file in profile dir**: a `balatrobot.lua` alongside `settings.lua` that could patch `G` globals directly. Rejected — arbitrary Lua execution defeats the simplicity goal and is hard to validate. + +**Architecture: hybrid resolution** + +- **Python side**: lightweight validation only. A typer callback checks that the `--settings` value matches `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` (no `/`, `..`, etc.). Does NOT resolve paths or check if the profile exists. +- **Lua side**: full resolution. Discovers the mod directory via `SMODS.current_mod.path`, looks up `<moddir>/src/lua/profiles/<name>/`, loads files. If not found: sends error message listing available profiles, then aborts mod loading (HTTP server does not start). + +**Profile structure:** + +``` +src/lua/profiles/ +├── default/ +│ ├── settings.lua +│ └── profile.lua +├── fast/ +│ ├── settings.lua +│ └── profile.lua +├── turbo/ +│ ├── settings.lua +│ └── profile.lua +└── light/ + ├── settings.lua + └── profile.lua +``` + +- `settings.lua` — returns a Lua table deep-merged into `G.SETTINGS`. Required. +- `profile.lua` — returns a Lua table deep-merged into `G.PROFILES[n]`. Optional; if missing, the merge is skipped. + +**`default` profile:** Always applied when `--settings` is omitted or `BALATROBOT_SETTINGS` is unset. The fallback to `"default"` happens in the Lua `settings.lua`, not in Python. No escape hatch (`--settings none` does not exist). + +**Why the "BalatroBot" profile gate:** BalatroBot needs `all_unlocked = true` and tutorial skipped to function as a bot platform. These can't be profile settings because they affect meta state that's consumed before SMODS loads (see boot sequence: `init_item_prototypes` runs at step 7, SMODS at step 8). Rather than hooking earlier via Lovely patches, we gate on the in-game profile name — the user creates a dedicated "BalatroBot" profile, and the mod only activates when that profile is selected. This protects the user's real save data (no accidental overwrites) and makes the activation condition visible in the game's own UI. diff --git a/docs/api.md b/docs/api.md index 6f3ed22f..29c7c2b2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,7 +68,7 @@ curl -X POST http://127.0.0.1:12346 \ ```bash curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "RED", "stake": "WHITE"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_red", "stake": "stake_white"}, "id": 1}' ``` #### 4. Select Blind and Play Cards @@ -208,7 +208,7 @@ Start a new game run. ```bash curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "BLUE", "stake": "WHITE", "seed": "TEST123"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "start", "params": {"deck": "b_blue", "stake": "stake_white", "seed": "TEST123"}, "id": 1}' ``` --- @@ -395,7 +395,7 @@ curl -X POST http://127.0.0.1:12346 \ ### `sell` -Sell a joker or consumable. +Sell a joker or consumable. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (to make room for new jokers). **Parameters:** (exactly one required) @@ -406,7 +406,7 @@ Sell a joker or consumable. **Returns:** [GameState](#gamestate-schema) -**Errors:** `BAD_REQUEST`, `NOT_ALLOWED` +**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED` **Example:** @@ -598,7 +598,7 @@ Add a card to the game (debug/testing). Supports jokers, consumables, vouchers, | ------------- | ------- | -------- | ------------------------------------------------------------------------------ | | `key` | string | Yes | [Card key](#card-keys) (e.g., `j_joker`, `c_fool`, `p_arcana_normal_1`, `H_A`) | | `seal` | string | No | [Seal](#card-modifier-seal) type (playing cards only) | -| `edition` | string | No | [Edition](#card-modifier-edition) type (not vouchers or packs) | +| `edition` | string | No | [Edition](#card-modifier-edition) key (e.g. `e_foil`, not vouchers or packs) | | `enhancement` | string | No | [Enhancement](#card-modifier-enhancement) type (playing cards only) | | `eternal` | boolean | No | Cannot be sold/destroyed (jokers only) | | `perishable` | integer | No | Rounds until perish (jokers only) | @@ -616,7 +616,7 @@ Add a card to the game (debug/testing). Supports jokers, consumables, vouchers, # Add a Polychrome Joker curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ - -d '{"jsonrpc": "2.0", "method": "add", "params": {"key": "j_joker", "edition": "POLYCHROME"}, "id": 1}' + -d '{"jsonrpc": "2.0", "method": "add", "params": {"key": "j_joker", "edition": "e_polychrome"}, "id": 1}' # Add an Arcana Pack to the shop (requires SHOP state) curl -X POST http://127.0.0.1:12346 \ @@ -656,27 +656,33 @@ Set in-game values (debug/testing). **Parameters:** (at least one required) -| Name | Type | Required | Description | -| ---------- | ------- | -------- | ------------------------------- | -| `money` | integer | No | Set money amount | -| `chips` | integer | No | Set chips scored | -| `ante` | integer | No | Set ante number | -| `round` | integer | No | Set round number | -| `hands` | integer | No | Set hands remaining | -| `discards` | integer | No | Set discards remaining | -| `shop` | boolean | No | Re-stock shop (SHOP state only) | +| Name | Type | Required | Description | +| ---------- | ------- | -------- | --------------------------------------------------------------- | +| `money` | integer | No | Set money amount | +| `chips` | integer | No | Set chips scored | +| `ante` | integer | No | Set ante number | +| `round` | integer | No | Set round number | +| `hands` | integer | No | Set hands remaining | +| `discards` | integer | No | Set discards remaining | +| `shop` | boolean | No | Re-stock shop (SHOP state only) | +| `boss` | string | No | Override Boss Blind (BLIND_SELECT state, boss must be Upcoming) | **Returns:** [GameState](#gamestate-schema) **Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED` -**Example:** +**Examples:** ```bash # Set money to 100 and hands to 5 curl -X POST http://127.0.0.1:12346 \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "set", "params": {"money": 100, "hands": 5}, "id": 1}' + +# Override boss blind to The Hook +curl -X POST http://127.0.0.1:12346 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "method": "set", "params": {"boss": "bl_hook"}, "id": 1}' ``` --- @@ -690,14 +696,16 @@ The complete game state returned by most methods. ```json { "state": "SELECTING_HAND", + "paused": false, "round_num": 1, "ante_num": 1, "money": 4, - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "ABC123", "won": false, "used_vouchers": {}, + "tags": [ ... ], "hands": { ... }, "round": { ... }, "blinds": { ... }, @@ -775,13 +783,29 @@ Represents a card area (hand, jokers, consumables, shop, etc.). ```json { + "key": "bl_small", "type": "SMALL", "status": "SELECT", "name": "Small Blind", "effect": "No special effect", "score": 300, - "tag_name": "Uncommon Tag", - "tag_effect": "Shop has a free Uncommon Joker" + "tag": { + "key": "tag_juggle", + "name": "Juggle Tag", + "effect": "+3 hand size next round" + } +} +``` + +### Tag + +Represents a Balatro tag that provides bonuses when triggered. + +```json +{ + "key": "tag_juggle", + "name": "Juggle Tag", + "effect": "+3 hand size next round" } ``` @@ -805,36 +829,36 @@ Represents a card area (hand, jokers, consumables, shop, etc.). ### Deck -| Value | Description | -| ----------- | ------------------------------------------------------------- | -| `RED` | +1 discard every round | -| `BLUE` | +1 hand every round | -| `YELLOW` | Start with extra $10 | -| `GREEN` | $2 per remaining Hand, $1 per remaining Discard (no interest) | -| `BLACK` | +1 Joker slot, -1 hand every round | -| `MAGIC` | Start with Crystal Ball voucher and 2 copies of The Fool | -| `NEBULA` | Start with Telescope voucher, -1 consumable slot | -| `GHOST` | Spectral cards may appear in shop, start with Hex card | -| `ABANDONED` | Start with no Face Cards | -| `CHECKERED` | Start with 26 Spades and 26 Hearts | -| `ZODIAC` | Start with Tarot Merchant, Planet Merchant, and Overstock | -| `PAINTED` | +2 hand size, -1 Joker slot | -| `ANAGLYPH` | Gain Double Tag after each Boss Blind | -| `PLASMA` | Balanced Chips/Mult, 2X base Blind size | -| `ERRATIC` | Randomized Ranks and Suits | +| Value | Description | +| ------------- | ------------------------------------------------------------- | +| `b_red` | +1 discard every round | +| `b_blue` | +1 hand every round | +| `b_yellow` | Start with extra $10 | +| `b_green` | $2 per remaining Hand, $1 per remaining Discard (no interest) | +| `b_black` | +1 Joker slot, -1 hand every round | +| `b_magic` | Start with Crystal Ball voucher and 2 copies of The Fool | +| `b_nebula` | Start with Telescope voucher, -1 consumable slot | +| `b_ghost` | Spectral cards may appear in shop, start with Hex card | +| `b_abandoned` | Start with no Face Cards | +| `b_checkered` | Start with 26 Spades and 26 Hearts | +| `b_zodiac` | Start with Tarot Merchant, Planet Merchant, and Overstock | +| `b_painted` | +2 hand size, -1 Joker slot | +| `b_anaglyph` | Gain Double Tag after each Boss Blind | +| `b_plasma` | Balanced Chips/Mult, 2X base Blind size | +| `b_erratic` | Randomized Ranks and Suits | ### Stake -| Value | Description | -| -------- | ------------------------------- | -| `WHITE` | Base difficulty | -| `RED` | Small Blind gives no reward | -| `GREEN` | Required score scales faster | -| `BLACK` | Shop can have Eternal Jokers | -| `BLUE` | -1 Discard | -| `PURPLE` | Required score scales faster | -| `ORANGE` | Shop can have Perishable Jokers | -| `GOLD` | Shop can have Rental Jokers | +| Value | Description | +| -------------- | ------------------------------- | +| `stake_white` | Base difficulty | +| `stake_red` | Small Blind gives no reward | +| `stake_green` | Required score scales faster | +| `stake_black` | Shop can have Eternal Jokers | +| `stake_blue` | -1 Discard | +| `stake_purple` | Required score scales faster | +| `stake_orange` | Shop can have Perishable Jokers | +| `stake_gold` | Shop can have Rental Jokers | ### Card Value Suit @@ -880,32 +904,32 @@ Represents a card area (hand, jokers, consumables, shop, etc.). | Value | Description | | -------- | ------------------------------------------ | -| `RED` | Retrigger card 1 time | -| `BLUE` | Creates Planet card for final hand if held | -| `GOLD` | Earn $3 when scored | -| `PURPLE` | Creates Tarot when discarded | +| `Red` | Retrigger card 1 time | +| `Blue` | Creates Planet card for final hand if held | +| `Gold` | Earn $3 when scored | +| `Purple` | Creates Tarot when discarded | ### Card Modifier Edition -| Value | Description | -| ------------ | --------------------------------- | -| `FOIL` | +50 Chips | -| `HOLO` | +10 Mult | -| `POLYCHROME` | X1.5 Mult | -| `NEGATIVE` | +1 slot (jokers/consumables only) | +| Value | Description | +| -------------- | --------------------------------- | +| `e_foil` | +50 Chips | +| `e_holo` | +10 Mult | +| `e_polychrome` | X1.5 Mult | +| `e_negative` | +1 slot (jokers/consumables only) | ### Card Modifier Enhancement -| Value | Description | -| ------- | ------------------------------------ | -| `BONUS` | +30 Chips when scored | -| `MULT` | +4 Mult when scored | -| `WILD` | Counts as every suit | -| `GLASS` | X2 Mult when scored | -| `STEEL` | X1.5 Mult while held | -| `STONE` | +50 Chips (no rank/suit) | -| `GOLD` | $3 if held at end of round | -| `LUCKY` | 1/5 chance +20 Mult, 1/15 chance $20 | +| Value | Description | +| --------- | ------------------------------------ | +| `m_bonus` | +30 Chips when scored | +| `m_mult` | +4 Mult when scored | +| `m_wild` | Counts as every suit | +| `m_glass` | X2 Mult when scored | +| `m_steel` | X1.5 Mult while held | +| `m_stone` | +50 Chips (no rank/suit) | +| `m_gold` | $3 if held at end of round | +| `m_lucky` | 1/5 chance +20 Mult, 1/15 chance $20 | ### Blind Type @@ -915,6 +939,39 @@ Represents a card area (hand, jokers, consumables, shop, etc.). | `BIG` | Can be skipped for a Tag | | `BOSS` | Cannot be skipped, has special effect | +### Boss Blind Keys + +| Key | Name | +| ----------------- | ----------- | +| `bl_hook` | The Hook | +| `bl_ox` | The Ox | +| `bl_mouth` | The Mouth | +| `bl_fish` | The Fish | +| `bl_club` | The Club | +| `bl_manacle` | The Manacle | +| `bl_tooth` | The Tooth | +| `bl_wall` | The Wall | +| `bl_house` | The House | +| `bl_mark` | The Mark | +| `bl_wheel` | The Wheel | +| `bl_arm` | The Arm | +| `bl_psychic` | The Psychic | +| `bl_goad` | The Goad | +| `bl_water` | The Water | +| `bl_eye` | The Eye | +| `bl_plant` | The Plant | +| `bl_needle` | The Needle | +| `bl_head` | The Head | +| `bl_window` | The Window | +| `bl_serpent` | The Serpent | +| `bl_pillar` | The Pillar | +| `bl_flint` | The Flint | +| `bl_final_bell` | The Bell | +| `bl_final_leaf` | The Leaf | +| `bl_final_vessel` | The Vessel | +| `bl_final_acorn` | The Acorn | +| `bl_final_heart` | The Heart | + ### Blind Status | Value | Description | @@ -925,6 +982,37 @@ Represents a card area (hand, jokers, consumables, shop, etc.). | `DEFEATED` | Previously beaten | | `SKIPPED` | Previously skipped | +### Tags + +Tags provide bonuses when triggered, typically after skipping a blind or defeating a boss blind. + +| Value | Description | +| ---------------- | ------------------------------------------------------------ | +| `tag_uncommon` | Shop has a free Uncommon Joker | +| `tag_rare` | Shop has a free Rare Joker | +| `tag_negative` | Next base edition shop Joker is free and becomes Negative | +| `tag_foil` | Next base edition shop Joker is free and becomes Foil | +| `tag_holo` | Next base edition shop Joker is free and becomes Holographic | +| `tag_polychrome` | Next base edition shop Joker is free and becomes Polychrome | +| `tag_investment` | Gain $25 after defeating the next Boss Blind | +| `tag_voucher` | Adds one Voucher to the next shop | +| `tag_boss` | Rerolls the Boss Blind | +| `tag_standard` | Gives a free Mega Standard Pack | +| `tag_charm` | Gives a free Mega Arcana Pack | +| `tag_meteor` | Gives a free Mega Celestial Pack | +| `tag_buffoon` | Gives a free Mega Buffoon Pack | +| `tag_handy` | Gives $1 per played hand this run | +| `tag_garbage` | Gives $1 per unused discard this run | +| `tag_ethereal` | Gives a free Spectral Pack | +| `tag_coupon` | Initial cards and booster packs in next shop are free | +| `tag_double` | Gives a copy of the next selected Tag (Double Tag excluded) | +| `tag_juggle` | +3 hand size next round | +| `tag_d_six` | Rerolls in next shop start at $0 | +| `tag_top_up` | Create up to 2 Common Jokers (Must have room) | +| `tag_skip` | Gives $5 per skipped Blind this run | +| `tag_orbital` | Upgrade [poker hand] by 3 levels | +| `tag_economy` | Doubles your money (Max of $40) | + ### Card Keys Card keys are used with the `add` method and appear in the `key` field of Card objects. diff --git a/docs/cli.md b/docs/cli.md index 133f6d95..9c33faef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,37 +25,51 @@ Start Balatro with the BalatroBot mod loaded and API server running. uvx balatrobot serve [OPTIONS] ``` +### Profile Activation + +The BalatroBot mod only activates when the selected Balatro in-game profile is named exactly `"BalatroBot"` (case-sensitive). If no such profile exists, the HTTP server does not start and no settings overrides are applied. The game boots normally. + ### Options All options can be set via CLI flags or environment variables. CLI flags override environment variables. -| CLI Flag | Environment Variable | Default | Description | -| ------------------------------- | -------------------------------- | ------------- | ------------------------------------------ | -| `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | -| `--port PORT` | `BALATROBOT_PORT` | `12346` | Server port | -| `--fast` | `BALATROBOT_FAST` | `0` | Enable fast mode (10x game speed) | -| `--headless` | `BALATROBOT_HEADLESS` | `0` | Enable headless mode (minimal rendering) | -| `--render-on-api` | `BALATROBOT_RENDER_ON_API` | `0` | Render only on API calls | -| `--audio` | `BALATROBOT_AUDIO` | `0` | Enable audio | -| `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | -| `--no-shaders` | `BALATROBOT_NO_SHADERS` | `0` | Disable all shaders | -| `--fps-cap FPS_CAP` | `BALATROBOT_FPS_CAP` | `60` | Maximum FPS cap | -| `--gamespeed GAMESPEED` | `BALATROBOT_GAMESPEED` | `4` | Game speed multiplier | -| `--animation-fps ANIMATION_FPS` | `BALATROBOT_ANIMATION_FPS` | `10` | Animation FPS | -| `--no-reduced-motion` | `BALATROBOT_NO_REDUCED_MOTION` | `0` | Disable reduced motion | -| `--pixel-art-smoothing` | `BALATROBOT_PIXEL_ART_SMOOTHING` | `0` | Enable pixel art smoothing | -| `--balatro-path BALATRO_PATH` | `BALATROBOT_BALATRO_PATH` | auto-detected | Path to Balatro game directory | -| `--lovely-path LOVELY_PATH` | `BALATROBOT_LOVELY_PATH` | auto-detected | Path to lovely library (dll/so/dylib) | -| `--love-path LOVE_PATH` | `BALATROBOT_LOVE_PATH` | auto-detected | Path to game launcher executable | -| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, linux, windows, native | -| `--logs-path LOGS_PATH` | `BALATROBOT_LOGS_PATH` | `logs` | Directory for log files | -| `-h, --help` | - | - | Show help message and exit | - -!!! note "Mutually Exclusive Flags" - - `--headless` and `--render-on-api` are mutually exclusive. - -**Note:** Boolean flags (`--fast`, `--headless`, etc.) use `1` for enabled and `0` for disabled when set via environment variables. +| CLI Flag | Environment Variable | Default | Description | +| --------------------- | ------------------------- | ------------- | -------------------------------------------------- | +| `--settings NAME` | `BALATROBOT_SETTINGS` | `default` | Settings profile name | +| `--render MODE` | `BALATROBOT_RENDER` | `headfull` | Render mode: `headfull`, `headless`, or `ondemand` | +| `--debug` | `BALATROBOT_DEBUG` | `0` | Enable debug mode (requires DebugPlus mod) | +| `--host HOST` | `BALATROBOT_HOST` | `127.0.0.1` | Server hostname | +| `--num N` | - | `1` | Number of instances to start (CLI only) | +| `--path-balatro PATH` | `BALATROBOT_PATH_BALATRO` | auto-detected | Path to Balatro game directory | +| `--path-lovely PATH` | `BALATROBOT_PATH_LOVELY` | auto-detected | Path to lovely library (dll/so/dylib) | +| `--path-love PATH` | `BALATROBOT_PATH_LOVE` | auto-detected | Path to game launcher executable | +| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: `darwin`, `linux`, `windows`, `native` | +| `--path-logs PATH` | `BALATROBOT_PATH_LOGS` | `logs` | Directory for log files | +| `-h, --help` | - | - | Show help message and exit | + +### Render Modes + +| Mode | Behavior | +| ---------- | --------------------------------------------------------------------------------------------- | +| `headfull` | Normal rendering. Game window visible and fully interactive. | +| `headless` | All rendering disabled. Window hidden at 1×1 pixels. Use for CI/automated environments. | +| `ondemand` | Frames rendered only when explicitly requested via the API. Use with the screenshot endpoint. | + +### Settings Profiles + +BalatroBot bundles settings profiles that configure Balatro's game settings (speed, graphics, audio, window, etc.). Use `--settings` with a bare profile name: + +```bash +# Use the "fast" profile (max speed, no audio, minimal graphics) +uvx balatrobot serve --settings fast + +# Default profile is applied when --settings is omitted +uvx balatrobot serve +``` + +Available profiles: `default`, `fast`, `turbo`, `light`. + +The profile contains `settings.lua` (merged into `G.SETTINGS`) and optionally `profile.lua` (merged into `G.PROFILES`). Profiles live in `src/lua/profiles/<name>/`. Custom profiles can be added by creating a new directory with a `settings.lua` file. ## api Command @@ -95,7 +109,7 @@ uvx balatrobot api health uvx balatrobot api gamestate # Start a new game with Red Deck -uvx balatrobot api start '{"deck": "RED", "stake": "WHITE"}' +uvx balatrobot api start '{"deck": "b_red", "stake": "WHITE"}' # Play cards at indices 0 and 2 uvx balatrobot api play '{"cards": [0, 2]}' @@ -114,27 +128,24 @@ On error, prints `Error: NAME - message` to stderr (exit code 1). ### Basic Usage ```bash -# Start with default settings +# Start with default settings profile (headfull) uvx balatrobot serve -# Start with fast mode for development -uvx balatrobot serve --fast +# Start headless with the fast profile +uvx balatrobot serve --settings fast --render headless # Start with debug mode (requires DebugPlus mod) -uvx balatrobot serve --fast --debug - -# Start headless for automated testing -uvx balatrobot serve --headless --fast +uvx balatrobot serve --settings fast --debug ``` ### Custom Configuration ```bash -# Use a different port -uvx balatrobot serve --port 8080 - # Custom Balatro installation -uvx balatrobot serve --balatro-path /path/to/Balatro.exe +uvx balatrobot serve --path-balatro /path/to/Balatro + +# On-demand rendering for screenshot capture +uvx balatrobot serve --render ondemand ``` ## Examples with Environment Variables @@ -143,21 +154,17 @@ uvx balatrobot serve --balatro-path /path/to/Balatro.exe ```bash # Configure via environment variables -export BALATROBOT_PORT=8080 -export BALATROBOT_FAST=1 +export BALATROBOT_RENDER=headless +export BALATROBOT_SETTINGS=fast # Launch with defaults from env vars uvx balatrobot serve - -# CLI flags override env vars -uvx balatrobot serve --port 9000 # Uses port 9000, not 8080 ``` **Windows PowerShell:** ```powershell -$env:BALATROBOT_PORT = "8080" -$env:BALATROBOT_FAST = "1" +$env:BALATROBOT_RENDER = "headless" uvx balatrobot serve ``` @@ -177,8 +184,8 @@ The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detec **Auto-Detected Paths:** -- `BALATROBOT_LOVE_PATH`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe` -- `BALATROBOT_LOVELY_PATH`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll` +- `BALATROBOT_PATH_LOVE`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe` +- `BALATROBOT_PATH_LOVELY`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll` **Requirements:** @@ -190,10 +197,10 @@ The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detec ```powershell # Auto-detects paths -uvx balatrobot serve --fast +uvx balatrobot serve --render headless # Or specify custom paths -uvx balatrobot serve --love-path "C:\Custom\Path\Balatro.exe" --lovely-path "C:\Custom\Path\version.dll" +uvx balatrobot serve --path-love "C:\Custom\Path\Balatro.exe" --path-lovely "C:\Custom\Path\version.dll" ``` ### macOS Platform @@ -202,8 +209,8 @@ The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects **Auto-Detected Paths:** -- `BALATROBOT_LOVE_PATH`: `~/Library/Application Support/Steam/steamapps/common/Balatro/Balatro.app/Contents/MacOS/love` -- `BALATROBOT_LOVELY_PATH`: `~/Library/Application Support/Steam/steamapps/common/Balatro/liblovely.dylib` +- `BALATROBOT_PATH_LOVE`: `~/Library/Application Support/Steam/steamapps/common/Balatro/Balatro.app/Contents/MacOS/love` +- `BALATROBOT_PATH_LOVELY`: `~/Library/Application Support/Steam/steamapps/common/Balatro/liblovely.dylib` **Requirements:** @@ -217,10 +224,10 @@ The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects ```bash # Auto-detects paths -uvx balatrobot serve --fast +uvx balatrobot serve --render headless # Or specify custom paths -uvx balatrobot serve --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" +uvx balatrobot serve --path-love "/path/to/love" --path-lovely "/path/to/liblovely.dylib" ``` ### Linux (Proton) Platform @@ -229,9 +236,9 @@ The `linux` platform launches Balatro via Steam Proton. The CLI auto-detects Ste **Auto-Detected Paths:** -- `BALATROBOT_BALATRO_PATH`: `~/.local/share/Steam/steamapps/common/Balatro` -- `BALATROBOT_LOVE_PATH`: Best available Proton executable (scans `steamapps/common/`) -- `BALATROBOT_LOVELY_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` +- `BALATROBOT_PATH_BALATRO`: `~/.local/share/Steam/steamapps/common/Balatro` +- `BALATROBOT_PATH_LOVE`: Best available Proton executable (scans `steamapps/common/`) +- `BALATROBOT_PATH_LOVELY`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` **Requirements:** @@ -244,10 +251,10 @@ The `linux` platform launches Balatro via Steam Proton. The CLI auto-detects Ste ```bash # Auto-detects paths -uvx balatrobot serve --fast +uvx balatrobot serve --render headless # Or specify custom paths -uvx balatrobot serve --love-path /path/to/proton --balatro-path /path/to/Balatro +uvx balatrobot serve --path-love /path/to/proton --path-balatro /path/to/Balatro ``` !!! warning "Steam Installation" @@ -260,9 +267,9 @@ The `native` platform runs Balatro from source code using the LÖVE framework in **Required Paths:** -- `BALATROBOT_BALATRO_PATH`: Directory containing Balatro source code with `main.lua` -- `BALATROBOT_LOVE_PATH`: Path to LÖVE executable (find with `which love`), e.g., `/usr/bin/love` -- `BALATROBOT_LOVELY_PATH`: Must be `/usr/local/lib/liblovely.so` +- `BALATROBOT_PATH_BALATRO`: Directory containing Balatro source code with `main.lua` +- `BALATROBOT_PATH_LOVE`: Path to LÖVE executable (find with `which love`), e.g., `/usr/bin/love` +- `BALATROBOT_PATH_LOVELY`: Must be `/usr/local/lib/liblovely.so` - Mods directory: `~/.config/love/Mods` (auto-discovered, used by lovely) - Settings directory: `~/.local/share/love/balatro` (must contain game settings) @@ -274,7 +281,7 @@ mkdir -p ~/.local/share/love/balatro cp -r /path/to/balatro/settings/* ~/.local/share/love/balatro/ # Launch with native platform -uvx balatrobot serve --platform native --balatro-path /path/to/balatro/source +uvx balatrobot serve --platform native --path-balatro /path/to/balatro/source ``` ??? tip "Hyprland Configuration" @@ -302,10 +309,10 @@ uvx balatrobot serve --platform native --balatro-path /path/to/balatro/source ## Troubleshooting -**Connection refused**: Ensure Balatro is running and the mod loaded successfully. Check logs in `logs/{timestamp}/{port}.log` for errors. +**Connection refused**: Ensure Balatro is running and the mod loaded successfully. Check logs in `logs/{timestamp}/{port}.log` for errors. Verify the in-game profile is named exactly `"BalatroBot"`. -**Mod not loading**: Verify that Lovely Injector and Steamodded are installed correctly. +**Mod not loading**: Verify that Lovely Injector and Steamodded are installed correctly. Ensure you have a Balatro profile named `"BalatroBot"` and it is selected. -**Port in use**: Change the port with `--port` or set `BALATROBOT_PORT` to a different value. +**Port in use**: Ports are allocated ephemerally. If you need a specific port, adjust your firewall rules to allow the ephemeral range. -**Game crashes**: Try disabling shaders with `--no-shaders` or running in headless mode with `--headless`. +**Game crashes**: Try running in headless mode with `--render headless` and the `fast` profile (`--settings fast`). diff --git a/docs/contributing.md b/docs/contributing.md index 945b6685..8295821f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -32,28 +32,32 @@ export PYTHONPATH="${PWD}/src:${PYTHONPATH}" export PYTHONPATH="${PWD}/tests:${PYTHONPATH}" # BALATROBOT env vars -export BALATROBOT_FAST=1 +export BALATROBOT_SETTINGS=fast +export BALATROBOT_RENDER=headless export BALATROBOT_DEBUG=1 -export BALATROBOT_LOVE_PATH='/path/to/Balatro/love' -export BALATROBOT_LOVELY_PATH='/path/to/liblovely.dylib' -export BALATROBOT_RENDER_ON_API=0 -export BALATROBOT_HEADLESS=1 -export BALATROBOT_AUDIO=0 +export BALATROBOT_PATH_LOVE='/path/to/Balatro/love' +export BALATROBOT_PATH_LOVELY='/path/to/liblovely.dylib' ``` **Setup:** Install [direnv](https://direnv.net/), then create `.envrc` in the project root with the above configuration, updating paths for your system. ### Lua LSP Configuration -The `.luarc.json` file should be placed at the root of the balatrobot repository. It configures the Lua Language Server for IDE support (autocomplete, diagnostics, type checking). +The `.luarc.json` file at the project root configures the Lua Language Server for IDE support (autocomplete, diagnostics, type checking). -!!! info "Update Library Paths" +!!! info "Gitignored — copy from template" - You **must** update the `workspace.library` paths in `.luarc.json` to match your system: + `.luarc.json` is **gitignored** and not committed to the repo. Copy the example below to create your own, or check the current version in the CI logs / ask a maintainer. - - Steamodded LSP definitions: `path/to/Mods/smods/lsp_def` - - Love2D library: `path/to/love2d/library` (clone locally: [LuaCATS/love2d](https://github.com/LuaCATS/love2d)) - - LuaSocket library: `path/to/luasocket/library` (clone locally: [LuaCATS/luasocket](https://github.com/LuaCATS/luasocket)) + SMODS scans all `.json` files in mod directories and will log a harmless error for `.luarc.json` — this is expected and does not affect mod loading. + +!!! info "Library Paths" + + You **must** update the `workspace.library` paths to match your system: + + - **Steamodded LSP definitions:** `/path/to/Balatro/Mods/smods/lsp_def`. + - **Love2D library:** `/path/to/love2d/library` (clone locally: [LuaCATS/love2d](https://github.com/LuaCATS/love2d)) + - **LuaSocket library:** `/path/to/luasocket/library` (clone locally: [LuaCATS/luasocket](https://github.com/LuaCATS/luasocket)) **Example `.luarc.json`:** @@ -76,7 +80,12 @@ The `.luarc.json` file should be placed at the root of the balatrobot repository "G", "BB_GAMESTATE", "BB_ERROR_NAMES", - "BB_ENDPOINTS" + "BB_ENDPOINTS", + "localize", + "get_blind_amount", + "get_compressed", + "save_run", + "compress_and_save" ] }, "type": { diff --git a/docs/example-bot.md b/docs/example-bot.md index 4a758bd7..9e22981f 100644 --- a/docs/example-bot.md +++ b/docs/example-bot.md @@ -36,7 +36,7 @@ def play_game(): """Play a complete game of Balatro.""" # Return to menu and start a new game rpc("menu") - state = rpc("start", {"deck": "RED", "stake": "WHITE"}) + state = rpc("start", {"deck": "b_red", "stake": "WHITE"}) print(f"Started game with seed: {state['seed']}") # Main game loop diff --git a/docs/installation.md b/docs/installation.md index d3bafa94..9f0a8a33 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -68,7 +68,7 @@ Expected response: - **Connection refused**: Ensure Balatro is running and the mod loaded successfully - **Mod not loading**: Check that Lovely and Steamodded are installed correctly -- **Port in use**: Use `uvx balatrobot serve --port PORT` to specify a different port +- **Port in use**: Ports are allocated ephemerally. Check the state file or logs for the actual port assigned. - **No display server (Linux)**: Ensure `DISPLAY` or `WAYLAND_DISPLAY` is set in your environment For more troubleshooting help, see the [CLI Reference](cli.md). diff --git a/mkdocs.yml b/mkdocs.yml index 9a11e396..260555ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,8 @@ repo_name: "coder/balatrobot" repo_url: https://github.com/coder/balatrobot site_url: https://coder.github.io/balatrobot/ docs_dir: docs/ +exclude_docs: | + adr/ theme: name: material favicon: assets/balatrobot.svg @@ -31,6 +33,10 @@ theme: name: Switch to light mode extra: generator: false + version: + provider: mike + default: latest + alias: true plugins: - search - llmstxt: diff --git a/pyproject.toml b/pyproject.toml index 6881818f..a51b13db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,12 @@ authors = [ { name = "phughesion" }, ] requires-python = ">=3.13" -dependencies = ["httpx>=0.28.1", "typer>=0.15"] +dependencies = [ + "httpx>=0.28.1", + "platformdirs>=4.0", + "typer>=0.15", + "tqdm>=4.67.1", +] classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", @@ -41,8 +46,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" addopts = "--dist loadscope" markers = [ - "dev: marks tests that are currently developed", - "integration: marks integration tests that actually start Balatro (deselect with '-m \"not integration\"')", + "dev: marks tests currently being developed (run with `-m dev`, remove when done)", ] [tool.commitizen] @@ -55,7 +59,6 @@ test = [ "pytest-asyncio>=1.3.0", "pytest-rerunfailures>=16.1", "pytest-xdist[psutil]>=3.8.0", - "tqdm>=4.67.1", ] dev = [ "commitizen>=4.11.0", @@ -66,6 +69,7 @@ dev = [ "mdformat-gfm-alerts>=2.0.0", "mdformat-mkdocs>=5.1.1", "mdformat-simple-breaks>=0.0.1", + "mike>=2.2.0", "mkdocs>=1.6.1", "mkdocs-llmstxt>=0.5.0", "mkdocs-material>=9.7.1", diff --git a/src/balatrobot/__init__.py b/src/balatrobot/__init__.py index 970a360d..4edc3b89 100644 --- a/src/balatrobot/__init__.py +++ b/src/balatrobot/__init__.py @@ -2,7 +2,13 @@ from balatrobot.cli.client import APIError, BalatroClient from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance __version__ = "1.5.2" -__all__ = ["APIError", "BalatroClient", "BalatroInstance", "Config", "__version__"] +__all__ = [ + "APIError", + "BalatroClient", + "BalatroInstance", + "Config", + "__version__", +] diff --git a/src/balatrobot/cli/__init__.py b/src/balatrobot/cli/__init__.py index 974828e7..09e55cfb 100644 --- a/src/balatrobot/cli/__init__.py +++ b/src/balatrobot/cli/__init__.py @@ -3,7 +3,9 @@ import typer from balatrobot.cli.api import api +from balatrobot.cli.list import list_cmd from balatrobot.cli.serve import serve +from balatrobot.cli.stop import stop app = typer.Typer( name="balatrobot", @@ -14,6 +16,8 @@ # Register commands app.command()(serve) app.command()(api) +app.command(name="list")(list_cmd) +app.command()(stop) def main() -> None: diff --git a/src/balatrobot/cli/api.py b/src/balatrobot/cli/api.py index 7e20b6ca..2392a705 100644 --- a/src/balatrobot/cli/api.py +++ b/src/balatrobot/cli/api.py @@ -2,12 +2,14 @@ import json from enum import StrEnum +from pathlib import Path from typing import Annotated import httpx import typer from balatrobot.cli.client import APIError, BalatroClient +from balatrobot.state import StateFile class Method(StrEnum): @@ -36,13 +38,201 @@ class Method(StrEnum): USE = "use" +# --------------------------------------------------------------------------- +# Replay helpers +# --------------------------------------------------------------------------- + + +def _load_requests(path: Path) -> list[dict]: + """Load and validate a JSONL requests file. + + Returns list of parsed JSON-RPC request dicts. + Raises typer.Exit on validation failure. + """ + lines = path.read_text().splitlines() + if not lines: + typer.echo("Error: requests file is empty", err=True) + raise typer.Exit(code=1) + + requests: list[dict] = [] + for i, line in enumerate(lines, 1): + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + typer.echo(f"Error: invalid JSON on line {i}: {e}", err=True) + raise typer.Exit(code=1) + if not isinstance(obj, dict) or "method" not in obj: + typer.echo(f"Error: line {i} is not a valid JSON-RPC request", err=True) + raise typer.Exit(code=1) + requests.append(obj) + return requests + + +def _load_responses(path: Path) -> list[dict]: + """Load a JSONL responses file. + + Returns list of parsed JSON-RPC response dicts. + Raises typer.Exit on validation failure. + """ + lines = path.read_text().splitlines() + responses: list[dict] = [] + for i, line in enumerate(lines, 1): + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + typer.echo(f"Error: invalid JSON in responses on line {i}: {e}", err=True) + raise typer.Exit(code=1) + responses.append(obj) + return responses + + +def _replay( + requests: list[dict], + responses: list[dict] | None, + client: BalatroClient, +) -> None: + """Replay requests against a live server, optionally verifying responses. + + Raises typer.Exit on first error or divergence. + """ + try: + from tqdm import tqdm as _tqdm + + iterator = _tqdm(requests, desc="Replaying", unit="req") + except ImportError: + iterator = requests + + for i, req in enumerate(iterator): + method = req["method"] + params = req.get("params", {}) + + try: + result = client.call(method, params) + except APIError as e: + typer.echo( + f"\nError: API error on request {i + 1}: {e.name} - {e.message}", + err=True, + ) + raise typer.Exit(code=1) + except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e: + typer.echo(f"\nError: connection failed on request {i + 1}: {e}", err=True) + raise typer.Exit(code=1) + + if responses is not None: + expected = responses[i] + expected_result = expected.get("result") + if result != expected_result: + typer.echo(f"\nDivergence at request {i + 1}:", err=True) + typer.echo(f" expected: {json.dumps(expected, indent=2)}", err=True) + typer.echo( + f" actual: {json.dumps({'jsonrpc': '2.0', 'result': result, 'id': req.get('id')}, indent=2)}", + err=True, + ) + raise typer.Exit(code=1) + + typer.echo(f"Replayed {len(requests)} requests successfully.") + + +# --------------------------------------------------------------------------- +# Resolve host/port (shared between single-call and replay) +# --------------------------------------------------------------------------- + + +def _resolve_target( + host: str | None, + port: int | None, + index: int | None, +) -> tuple[str, int]: + """Resolve host and port from explicit values or state file.""" + if (host is None) != (port is None): + typer.echo("Error: --host and --port must be provided together.", err=True) + raise typer.Exit(code=1) + + if host is not None and port is not None: + return host, port + + try: + info = StateFile.resolve(index=index) + return info.host, info.port + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) + + +# --------------------------------------------------------------------------- +# Command +# --------------------------------------------------------------------------- + + def api( - method: Annotated[Method, typer.Argument(help="API method to call")], - params: Annotated[str, typer.Argument(help="JSON params object")] = "{}", - host: Annotated[str, typer.Option(help="Server hostname")] = "127.0.0.1", - port: Annotated[int, typer.Option(help="Server port")] = 12346, + method: Annotated[ + Method | None, + typer.Argument(help="API method to call"), + ] = None, + params: Annotated[ + str, + typer.Argument(help="JSON params object"), + ] = "{}", + host: Annotated[str | None, typer.Option(help="Server hostname")] = None, + port: Annotated[int | None, typer.Option(help="Server port")] = None, + index: Annotated[ + int | None, typer.Option("--index", "-i", help="Instance index (default: 0)") + ] = None, + requests_path: Annotated[ + Path | None, + typer.Option("--requests", help="JSONL file of requests to replay"), + ] = None, + responses_path: Annotated[ + Path | None, + typer.Option("--responses", help="JSONL file of responses to verify against"), + ] = None, ) -> None: - """Call API endpoint on a running BalatroBot server.""" + """Call API endpoint on a running BalatroBot server. + + Use --requests to replay a JSONL trace file. Mutually exclusive with + positional METHOD and PARAMS arguments. + """ + # --requests is mutually exclusive with positional method/params + if requests_path is not None: + if method is not None: + typer.echo( + "Error: --requests is mutually exclusive with positional METHOD.", + err=True, + ) + raise typer.Exit(code=1) + + if not requests_path.exists(): + typer.echo(f"Error: requests file not found: {requests_path}", err=True) + raise typer.Exit(code=1) + + reqs = _load_requests(requests_path) + + resps: list[dict] | None = None + if responses_path is not None: + if not responses_path.exists(): + typer.echo( + f"Error: responses file not found: {responses_path}", err=True + ) + raise typer.Exit(code=1) + resps = _load_responses(responses_path) + if len(resps) != len(reqs): + typer.echo( + f"Error: line count mismatch — {len(reqs)} requests vs " + f"{len(resps)} responses", + err=True, + ) + raise typer.Exit(code=1) + + target_host, target_port = _resolve_target(host, port, index) + client = BalatroClient(host=target_host, port=target_port) + _replay(reqs, resps, client) + return + + # Single-call mode + if method is None: + typer.echo("Error: METHOD is required when not using --requests.", err=True) + raise typer.Exit(code=1) + # Validate JSON params try: params_dict = json.loads(params) @@ -50,8 +240,8 @@ def api( typer.echo(f"Error: Invalid JSON params - {e}", err=True) raise typer.Exit(code=1) - # Make API call - client = BalatroClient(host=host, port=port) + target_host, target_port = _resolve_target(host, port, index) + client = BalatroClient(host=target_host, port=target_port) try: result = client.call(method.value, params_dict) typer.echo(json.dumps(result, indent=2)) diff --git a/src/balatrobot/cli/list.py b/src/balatrobot/cli/list.py new file mode 100644 index 00000000..fd212472 --- /dev/null +++ b/src/balatrobot/cli/list.py @@ -0,0 +1,35 @@ +"""List command — show running BalatroBot instances.""" + +import json +from typing import Annotated + +import typer + +from balatrobot.state import StateFile + + +def list_cmd( + json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False, +) -> None: + """List running BalatroBot instances.""" + data = StateFile.read() + + if data is None or not data.get("instances"): + if json_output: + typer.echo(json.dumps({"instances": []})) + else: + typer.echo("No running instances.") + return + + if json_output: + typer.echo(json.dumps(data, indent=2)) + return + + instances = data["instances"] + started_at = data.get("started_at", "unknown") + typer.echo(f"Started: {started_at}") + typer.echo(f"Instances ({len(instances)}):") + for i, inst in enumerate(instances): + typer.echo( + f" [{i}] http://{inst['host']}:{inst['port']} log: {inst['log_path']}" + ) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 9c2af0cf..7716c094 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -1,64 +1,148 @@ -"""Serve command - Start Balatro with BalatroBot mod loaded.""" +"""Serve command — start Balatro with BalatroBot mod loaded.""" import asyncio +import os +import re +import signal +import sys +from pathlib import Path from typing import Annotated import typer from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import InstanceDiedError +from balatrobot.pool import BalatroPool +from balatrobot.state import StateFile, StateFileBusy, default_state_path # Platform choices for validation PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"] +# Regex for valid settings profile names +_SETTINGS_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") + + +def settings_callback(value: str | None) -> str | None: + """Validate --settings as a bare profile name. + + This is a courtesy guard for CLI users. The same regex is enforced + on the Lua side in apply_profile() — that validation is authoritative. + """ + if value is None: + return None + if not _SETTINGS_RE.match(value): + raise typer.BadParameter( + f"Must be a valid profile name (alphanumeric, hyphens, underscores). Got: '{value}'" + ) + return value + + +class Server: + """Owns the full serve lifecycle: pool start/stop, state file write/delete, + and a supervision loop that watches for SIGTERM or child-death. + + Usage:: + + async with Server(config, n=2) as server: + await server.run() + """ + + def __init__( + self, + config: Config, + n: int, + state_path: Path | None = None, + ) -> None: + self._config = config + self._n = n + self._state_path = state_path or default_state_path() + self._pool: BalatroPool | None = None + self._shutdown = asyncio.Event() + + @property + def pool(self) -> BalatroPool | None: + return self._pool + + async def __aenter__(self) -> "Server": + # 1. Check for existing live state file + existing = StateFile.read(self._state_path) + if existing is not None: + raise StateFileBusy(path=self._state_path, pid=existing["pid"]) + + # 2. Start pool + self._pool = BalatroPool(self._config, n=self._n) + try: + await self._pool.start() + # 3. Write state file + StateFile.write(self._state_path, os.getpid(), self._pool.instances) + except BaseException: + await self._pool.stop() + raise + + return self + + async def __aexit__(self, *args: object) -> None: + if self._pool is not None: + await self._pool.stop() + StateFile.delete(self._state_path) + + async def run(self) -> None: + """Block until SIGTERM or child death. + + Raises InstanceDiedError on child death. + """ + assert self._pool is not None # set by __aenter__ + loop = asyncio.get_running_loop() + + if sys.platform != "win32": + loop.add_signal_handler(signal.SIGTERM, self._shutdown.set) + + try: + while not self._shutdown.is_set(): + self._pool.check_alive() + try: + await asyncio.wait_for(self._shutdown.wait(), timeout=5) + except asyncio.TimeoutError: + pass + finally: + if sys.platform != "win32": + loop.remove_signal_handler(signal.SIGTERM) + def serve( # fmt: off - host: Annotated[ - str | None, typer.Option(help="Server hostname (default: 127.0.0.1)") - ] = None, - port: Annotated[ - int | None, typer.Option(help="Server port (default: 12346)") - ] = None, - fps_cap: Annotated[ - int | None, typer.Option(help="Maximum FPS cap (default: 60)") - ] = None, - gamespeed: Annotated[ - int | None, typer.Option(help="Game speed multiplier (default: 4)") - ] = None, - animation_fps: Annotated[ - int | None, typer.Option(help="Animation FPS (default: 10)") + num: Annotated[ + int, typer.Option("--num", help="Number of instances to start (default: 1)") + ] = 1, + settings: Annotated[ + str | None, + typer.Option( + "--settings", help="Settings profile name", callback=settings_callback + ), ] = None, - logs_path: Annotated[ - str | None, typer.Option(help="Directory for log files (default: logs)") + render: Annotated[ + str | None, + typer.Option("--render", help="Render mode: headfull|headless|ondemand"), ] = None, - fast: Annotated[ - bool | None, typer.Option(help="Enable fast mode (10x speed)") + debug: Annotated[ + bool | None, typer.Option("--debug", help="Enable debug endpoints") ] = None, - headless: Annotated[bool | None, typer.Option(help="Enable headless mode")] = None, - render_on_api: Annotated[ - bool | None, typer.Option(help="Render only on API calls") + host: Annotated[str | None, typer.Option("--host", help="Server hostname")] = None, + path_balatro: Annotated[ + str | None, typer.Option("--path-balatro", help="Path to Balatro directory") ] = None, - audio: Annotated[bool | None, typer.Option(help="Enable audio")] = None, - debug: Annotated[bool | None, typer.Option(help="Enable debug mode")] = None, - no_shaders: Annotated[bool | None, typer.Option(help="Disable shaders")] = None, - no_reduced_motion: Annotated[ - bool | None, typer.Option(help="Disable reduced motion") + path_lovely: Annotated[ + str | None, typer.Option("--path-lovely", help="Path to lovely library") ] = None, - pixel_art_smoothing: Annotated[ - bool | None, typer.Option(help="Enable pixel art smoothing") - ] = None, - balatro_path: Annotated[ - str | None, typer.Option(help="Path to Balatro executable") - ] = None, - lovely_path: Annotated[ - str | None, typer.Option(help="Path to lovely library") - ] = None, - love_path: Annotated[ - str | None, typer.Option(help="Path to game launcher executable") + path_love: Annotated[ + str | None, typer.Option("--path-love", help="Path to LOVE executable") ] = None, platform: Annotated[ - str | None, typer.Option(help="Platform (darwin, linux, windows, native)") + str | None, + typer.Option("--platform", help="Platform (darwin, linux, windows, native)"), + ] = None, + path_logs: Annotated[ + str | None, typer.Option("--path-logs", help="Log directory") ] = None, # fmt: on ) -> None: @@ -72,37 +156,47 @@ def serve( ) raise typer.Exit(code=1) + if num < 1: + typer.echo(f"Error: --num must be >= 1, got {num}.", err=True) + raise typer.Exit(code=1) + # Build config from kwargs with env var fallback - config = Config.from_kwargs( - host=host, - port=port, - fps_cap=fps_cap, - gamespeed=gamespeed, - animation_fps=animation_fps, - logs_path=logs_path, - fast=fast, - headless=headless, - render_on_api=render_on_api, - audio=audio, - debug=debug, - no_shaders=no_shaders, - no_reduced_motion=no_reduced_motion, - pixel_art_smoothing=pixel_art_smoothing, - balatro_path=balatro_path, - lovely_path=lovely_path, - love_path=love_path, - platform=platform, - ) + try: + config = Config.from_kwargs( + settings=settings, + render=render, + debug=debug, + host=host, + path_balatro=path_balatro, + path_lovely=path_lovely, + path_love=path_love, + platform=platform, + path_logs=path_logs, + ) + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(code=1) try: - asyncio.run(_serve(config)) + asyncio.run(_serve(config, num)) except KeyboardInterrupt: typer.echo("\nShutting down server...") + except InstanceDiedError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) + except StateFileBusy as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) -async def _serve(config: Config) -> None: - """Async serve implementation.""" - async with BalatroInstance(config) as instance: - typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") - while True: - await asyncio.sleep(5) +async def _serve(config: Config, n: int) -> None: + async with Server(config, n) as server: + pool = server.pool + assert pool is not None + for i, info in enumerate(pool.instances): + typer.echo(f"Instance [{i}]: {info.url}") + typer.echo( + f"Session: {pool.session_name} | Logs: {config.path_logs or 'logs'}/{pool.session_name}/" + ) + typer.echo("Press Ctrl+C to stop.") + await server.run() diff --git a/src/balatrobot/cli/stop.py b/src/balatrobot/cli/stop.py new file mode 100644 index 00000000..3d971dea --- /dev/null +++ b/src/balatrobot/cli/stop.py @@ -0,0 +1,51 @@ +"""Stop command — stop a running BalatroBot server.""" + +import os +import signal +import time + +import typer + +from balatrobot.state import StateFile + + +def stop() -> None: + """Stop a running BalatroBot server.""" + data = StateFile.read() + + if data is None: + typer.echo("No running instances.") + return + + pid = data.get("pid") + if pid is None: + typer.echo("No running instances.") + return + + # Send SIGTERM + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + # Already dead — treat as success + typer.echo(f"Server stopped (PID {pid}).") + return + except PermissionError: + typer.echo(f"Permission denied: PID {pid} is owned by another user.", err=True) + raise typer.Exit(code=1) + except OSError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) + + # Poll for process to die (100ms intervals, up to 5s) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError, OSError): + # Process is gone + typer.echo(f"Server stopped (PID {pid}).") + return + time.sleep(0.1) + + typer.echo(f"Timed out waiting for PID {pid} to stop.", err=True) + raise typer.Exit(code=1) diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index c036d7e8..797b5c66 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -4,49 +4,25 @@ from dataclasses import dataclass from typing import Any, Self -# Mapping: config field -> env var ENV_MAP: dict[str, str] = { "host": "BALATROBOT_HOST", - "port": "BALATROBOT_PORT", - "fast": "BALATROBOT_FAST", - "headless": "BALATROBOT_HEADLESS", - "render_on_api": "BALATROBOT_RENDER_ON_API", - "audio": "BALATROBOT_AUDIO", + "render": "BALATROBOT_RENDER", "debug": "BALATROBOT_DEBUG", - "no_shaders": "BALATROBOT_NO_SHADERS", - "fps_cap": "BALATROBOT_FPS_CAP", - "gamespeed": "BALATROBOT_GAMESPEED", - "animation_fps": "BALATROBOT_ANIMATION_FPS", - "no_reduced_motion": "BALATROBOT_NO_REDUCED_MOTION", - "pixel_art_smoothing": "BALATROBOT_PIXEL_ART_SMOOTHING", - "balatro_path": "BALATROBOT_BALATRO_PATH", - "lovely_path": "BALATROBOT_LOVELY_PATH", - "love_path": "BALATROBOT_LOVE_PATH", + "settings": "BALATROBOT_SETTINGS", + "path_balatro": "BALATROBOT_PATH_BALATRO", + "path_lovely": "BALATROBOT_PATH_LOVELY", + "path_love": "BALATROBOT_PATH_LOVE", "platform": "BALATROBOT_PLATFORM", - "logs_path": "BALATROBOT_LOGS_PATH", + "path_logs": "BALATROBOT_PATH_LOGS", } -BOOL_FIELDS = frozenset( - { - "fast", - "headless", - "render_on_api", - "audio", - "debug", - "no_shaders", - "no_reduced_motion", - "pixel_art_smoothing", - } -) -INT_FIELDS = frozenset({"port", "fps_cap", "gamespeed", "animation_fps"}) - - -def _parse_env_value(field: str, value: str) -> str | int | bool: - """Convert env var string to proper type. Raises ValueError on invalid int.""" - if field in BOOL_FIELDS: +RENDER_CHOICES = frozenset({"headfull", "headless", "ondemand"}) + + +def _parse_env_value(field: str, value: str) -> str | bool: + """Coerce env var string to the right Python type.""" + if field == "debug": return value in ("1", "true") - if field in INT_FIELDS: - return int(value) # Raises ValueError if invalid return value @@ -58,41 +34,28 @@ class Config: host: str = "127.0.0.1" port: int = 12346 - # Balatro - fast: bool = False - headless: bool = False - render_on_api: bool = False - audio: bool = False + # Settings profile name (bare name, e.g. "fast", "turbo", "light") + settings: str | None = None + + # Render mode + render: str = "headfull" + + # Debug debug: bool = False - no_shaders: bool = False - fps_cap: int = 60 - gamespeed: int = 4 - animation_fps: int = 10 - no_reduced_motion: bool = False - pixel_art_smoothing: bool = False # Launcher - balatro_path: str | None = None - lovely_path: str | None = None - love_path: str | None = None - - # Instance + path_balatro: str | None = None + path_lovely: str | None = None + path_love: str | None = None platform: str | None = None - logs_path: str = "logs" + path_logs: str | None = None - @classmethod - def from_args(cls, args) -> Self: - """Create Config from CLI args with env var fallback.""" - kwargs: dict[str, Any] = {} - - for field, env_var in ENV_MAP.items(): - cli_val = getattr(args, field, None) - if cli_val is not None: - kwargs[field] = cli_val - elif (env_val := os.environ.get(env_var)) is not None: - kwargs[field] = _parse_env_value(field, env_val) - - return cls(**kwargs) + def __post_init__(self) -> None: + if self.render not in RENDER_CHOICES: + raise ValueError( + f"Invalid render mode '{self.render}'. " + f"Choose from: {', '.join(sorted(RENDER_CHOICES))}" + ) @classmethod def from_env(cls) -> Self: @@ -125,9 +88,10 @@ def to_env(self) -> dict[str, str]: value = getattr(self, field) if value is None: continue - if field in BOOL_FIELDS: + if field == "debug": if value: env[env_var] = "1" else: env[env_var] = str(value) + env["BALATROBOT_PORT"] = str(self.port) return env diff --git a/src/balatrobot/manager.py b/src/balatrobot/instance.py similarity index 71% rename from src/balatrobot/manager.py rename to src/balatrobot/instance.py index 10e4412c..585d0972 100644 --- a/src/balatrobot/manager.py +++ b/src/balatrobot/instance.py @@ -2,7 +2,7 @@ import asyncio import subprocess -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import datetime from pathlib import Path @@ -12,27 +12,54 @@ from balatrobot.platforms import get_launcher from balatrobot.platforms.base import BaseLauncher + +@dataclass(frozen=True) +class InstanceInfo: + """Immutable metadata for a running Balatro instance.""" + + host: str + port: int + log_path: Path | None = None + + @property + def url(self) -> str: + """Full HTTP URL for this instance.""" + return f"http://{self.host}:{self.port}" + + HEALTH_TIMEOUT = 30.0 +class InstanceDiedError(Exception): + """Raised when a Balatro subprocess has exited unexpectedly.""" + + def __init__(self, port: int, log_path: str | None = None) -> None: + self.port = port + self.log_path = log_path + msg = f"Instance on port {port} died unexpectedly." + if log_path is not None: + msg += f"\nLog: {log_path}" + super().__init__(msg) + + class BalatroInstance: """Context manager for a single Balatro instance.""" def __init__( - self, config: Config | None = None, session_id: str | None = None, **overrides + self, config: Config | None = None, session_name: str | None = None, **overrides ) -> None: """Initialize a Balatro instance. Args: config: Base configuration. If None, uses Config from environment. - session_id: Optional session ID for log directory. If None, generated at start(). + session_name: Session directory name (timestamp). If None, generated at start(). **overrides: Override specific config fields (e.g., port=12347). """ base = config or Config.from_env() self._config = replace(base, **overrides) if overrides else base self._process: subprocess.Popen | None = None self._log_path: Path | None = None - self._session_id = session_id + self._session_name = session_name self._launcher: BaseLauncher | None = None @property @@ -79,9 +106,11 @@ async def start(self) -> None: if self._process is not None: raise RuntimeError("Instance already started") - # Create session directory (use provided session_id or generate one) - timestamp = self._session_id or datetime.now().strftime("%Y-%m-%dT%H-%M-%S") - session_dir = Path(self._config.logs_path) / timestamp + # Create session directory (use provided session_name or generate one) + session_name = self._session_name or datetime.now().strftime( + "%Y-%m-%dT%H-%M-%S" + ) + session_dir = Path(self._config.path_logs or "logs") / session_name session_dir.mkdir(parents=True, exist_ok=True) self._log_path = session_dir / f"{self._config.port}.log" @@ -129,6 +158,20 @@ async def stop(self) -> None: process.kill() await loop.run_in_executor(None, process.wait) + def check_alive(self) -> None: + """Check if the subprocess is still running. + + Raises InstanceDiedError if the process has exited. + Silently returns if the instance hasn't been started or is already stopped. + """ + if self._process is None: + return + if self._process.poll() is not None: + raise InstanceDiedError( + port=self._config.port, + log_path=str(self._log_path) if self._log_path is not None else None, + ) + async def __aenter__(self) -> "BalatroInstance": """Start instance on context entry.""" await self.start() diff --git a/src/balatrobot/platforms/base.py b/src/balatrobot/platforms/base.py index 838f24f5..f0bd87e6 100644 --- a/src/balatrobot/platforms/base.py +++ b/src/balatrobot/platforms/base.py @@ -54,6 +54,7 @@ async def start(self, config: Config, session_dir: Path) -> subprocess.Popen: """ self.validate_paths(config) env = self.build_env(config) + env["BALATROBOT_PATH_LOGS"] = str(session_dir.resolve()) cmd = self.build_cmd(config) log_path = session_dir / f"{config.port}.log" diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py index 66a98185..8654eddb 100644 --- a/src/balatrobot/platforms/linux.py +++ b/src/balatrobot/platforms/linux.py @@ -61,42 +61,42 @@ def validate_paths(self, config: Config) -> None: ) # Balatro game directory - if config.balatro_path is None: + if config.path_balatro is None: candidate = steam_root / "steamapps/common/Balatro" if candidate.is_dir(): - config.balatro_path = str(candidate) + config.path_balatro = str(candidate) - if config.balatro_path is None: + if config.path_balatro is None: raise RuntimeError( "Balatro game directory not found under Steam root. " - "Set --balatro-path or BALATROBOT_BALATRO_PATH." + "Set --path-balatro or BALATROBOT_PATH_BALATRO." ) - balatro = Path(config.balatro_path) + balatro = Path(config.path_balatro) if not balatro.is_dir() or not (balatro / "Balatro.exe").is_file(): raise RuntimeError(f"Balatro game directory not found: {balatro}") # Lovely (version.dll) - if config.lovely_path is None: + if config.path_lovely is None: candidate = balatro / "version.dll" if candidate.is_file(): - config.lovely_path = str(candidate) + config.path_lovely = str(candidate) - if config.lovely_path is None: + if config.path_lovely is None: raise RuntimeError( "lovely-injector version.dll not found. " - "Set --lovely-path or BALATROBOT_LOVELY_PATH." + "Set --path-lovely or BALATROBOT_PATH_LOVELY." ) # Proton executable - if config.love_path is None: + if config.path_love is None: detected = _detect_proton_path(steam_root) if detected: - config.love_path = str(detected) + config.path_love = str(detected) - if config.love_path is None: + if config.path_love is None: raise RuntimeError( - "Proton executable not found. Set --love-path or BALATROBOT_LOVE_PATH." + "Proton executable not found. Set --path-love or BALATROBOT_PATH_LOVE." ) def build_env(self, config: Config) -> dict[str, str]: @@ -118,10 +118,10 @@ def build_env(self, config: Config) -> dict[str, str]: def build_cmd(self, config: Config) -> list[str]: """Build Linux launch command via Proton.""" - assert config.love_path is not None - assert config.balatro_path is not None - balatro_exe = str(Path(config.balatro_path) / "Balatro.exe") - return [config.love_path, "run", balatro_exe] + assert config.path_love is not None + assert config.path_balatro is not None + balatro_exe = str(Path(config.path_balatro) / "Balatro.exe") + return [config.path_love, "run", balatro_exe] def cleanup(self, config: Config) -> None: """Shut down the Wine prefix via wineserver -k. @@ -131,11 +131,11 @@ def cleanup(self, config: Config) -> None: wineserver -k cleanly terminates all Wine processes and closes display connections so the compositor removes windows. """ - if config.love_path is None: + if config.path_love is None: return # wineserver lives next to the proton script - proton_dir = Path(config.love_path).parent + proton_dir = Path(config.path_love).parent wineserver = proton_dir / "files" / "bin" / "wineserver" if not wineserver.is_file(): return diff --git a/src/balatrobot/platforms/macos.py b/src/balatrobot/platforms/macos.py index ec67d00a..9ad5f6f0 100644 --- a/src/balatrobot/platforms/macos.py +++ b/src/balatrobot/platforms/macos.py @@ -12,21 +12,21 @@ class MacOSLauncher(BaseLauncher): def validate_paths(self, config: Config) -> None: """Validate paths, apply macOS defaults if None.""" - if config.love_path is None: - config.love_path = str( + if config.path_love is None: + config.path_love = str( Path.home() / "Library/Application Support/Steam/steamapps/common/Balatro" / "Balatro.app/Contents/MacOS/love" ) - if config.lovely_path is None: - config.lovely_path = str( + if config.path_lovely is None: + config.path_lovely = str( Path.home() / "Library/Application Support/Steam/steamapps/common/Balatro" / "liblovely.dylib" ) - love = Path(config.love_path) - lovely = Path(config.lovely_path) + love = Path(config.path_love) + lovely = Path(config.path_lovely) if not love.exists(): raise RuntimeError(f"LOVE executable not found: {love}") @@ -35,13 +35,13 @@ def validate_paths(self, config: Config) -> None: def build_env(self, config: Config) -> dict[str, str]: """Build environment with DYLD_INSERT_LIBRARIES.""" - assert config.lovely_path is not None + assert config.path_lovely is not None env = os.environ.copy() - env["DYLD_INSERT_LIBRARIES"] = config.lovely_path + env["DYLD_INSERT_LIBRARIES"] = config.path_lovely env.update(config.to_env()) return env def build_cmd(self, config: Config) -> list[str]: """Build macOS launch command.""" - assert config.love_path is not None - return [config.love_path] + assert config.path_love is not None + return [config.path_love] diff --git a/src/balatrobot/platforms/native.py b/src/balatrobot/platforms/native.py index ecc84653..0e8b4985 100644 --- a/src/balatrobot/platforms/native.py +++ b/src/balatrobot/platforms/native.py @@ -49,47 +49,47 @@ def validate_paths(self, config: Config) -> None: errors: list[str] = [] # balatro_path (required, no auto-detect) - if config.balatro_path is None: + if config.path_balatro is None: errors.append( "Game directory is required.\n" - " Set via: --balatro-path or BALATROBOT_BALATRO_PATH" + " Set via: --path-balatro or BALATROBOT_PATH_BALATRO" ) else: - balatro = Path(config.balatro_path) + balatro = Path(config.path_balatro) if not balatro.is_dir(): errors.append(f"Game directory not found: {balatro}") # lovely_path (required, auto-detect) - if config.lovely_path is None: + if config.path_lovely is None: detected = _detect_lovely_path() if detected: - config.lovely_path = str(detected) + config.path_lovely = str(detected) else: errors.append( "Lovely library is required.\n" - " Set via: --lovely-path or BALATROBOT_LOVELY_PATH\n" + " Set via: --path-lovely or BALATROBOT_PATH_LOVELY\n" " Expected: /usr/local/lib/liblovely.so" ) - if config.lovely_path: - lovely = Path(config.lovely_path) + if config.path_lovely: + lovely = Path(config.path_lovely) if not lovely.is_file(): errors.append(f"Lovely library not found: {lovely}") elif lovely.suffix != ".so": errors.append(f"Lovely library has wrong extension: {lovely}") # love_path (required, auto-detect via PATH) - if config.love_path is None: + if config.path_love is None: detected = _detect_love_path() if detected: - config.love_path = str(detected) + config.path_love = str(detected) else: errors.append( "LOVE executable is required.\n" - " Set via: --love-path or BALATROBOT_LOVE_PATH\n" + " Set via: --path-love or BALATROBOT_PATH_LOVE\n" " Or install love and ensure it's in PATH" ) - if config.love_path: - love = Path(config.love_path) + if config.path_love: + love = Path(config.path_love) if not love.is_file(): errors.append(f"LOVE executable not found: {love}") @@ -98,14 +98,14 @@ def validate_paths(self, config: Config) -> None: def build_env(self, config: Config) -> dict[str, str]: """Build environment with LD_PRELOAD.""" - assert config.lovely_path is not None + assert config.path_lovely is not None env = os.environ.copy() - env["LD_PRELOAD"] = config.lovely_path + env["LD_PRELOAD"] = config.path_lovely env.update(config.to_env()) return env def build_cmd(self, config: Config) -> list[str]: """Build native LOVE launch command.""" - assert config.love_path is not None - assert config.balatro_path is not None - return [config.love_path, config.balatro_path] + assert config.path_love is not None + assert config.path_balatro is not None + return [config.path_love, config.path_balatro] diff --git a/src/balatrobot/platforms/windows.py b/src/balatrobot/platforms/windows.py index f1583f9c..02ea336d 100644 --- a/src/balatrobot/platforms/windows.py +++ b/src/balatrobot/platforms/windows.py @@ -12,17 +12,17 @@ class WindowsLauncher(BaseLauncher): def validate_paths(self, config: Config) -> None: """Validate paths, apply Windows defaults if None.""" - if config.love_path is None: - config.love_path = ( + if config.path_love is None: + config.path_love = ( r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe" ) - if config.lovely_path is None: - config.lovely_path = ( + if config.path_lovely is None: + config.path_lovely = ( r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll" ) - love = Path(config.love_path) - lovely = Path(config.lovely_path) + love = Path(config.path_love) + lovely = Path(config.path_lovely) if not love.exists(): raise RuntimeError(f"Balatro executable not found: {love}") @@ -37,5 +37,5 @@ def build_env(self, config: Config) -> dict[str, str]: def build_cmd(self, config: Config) -> list[str]: """Build Windows launch command.""" - assert config.love_path is not None - return [config.love_path] + assert config.path_love is not None + return [config.path_love] diff --git a/src/balatrobot/pool.py b/src/balatrobot/pool.py new file mode 100644 index 00000000..ac24706d --- /dev/null +++ b/src/balatrobot/pool.py @@ -0,0 +1,126 @@ +"""BalatroPool — manages N BalatroInstance instances.""" + +import asyncio +from datetime import datetime + +from balatrobot.config import Config +from balatrobot.instance import BalatroInstance, InstanceInfo + + +class BalatroPool: + """Manages N BalatroInstance instances with port allocation. + + The pool creates ``n`` instances from a base config, assigning unique + ports to each. Use ``start()``/``stop()`` to manage the lifecycle + and ``check_alive()`` to detect child-death. + + Fail-fast: if any instance fails to start, all already-started + instances are stopped and the error is re-raised. + + **Not designed for restart.** Calling ``start()`` again after + ``stop()`` is undefined behaviour. + """ + + def __init__( + self, + config: Config, + n: int = 1, + ports: list[int] | None = None, + ) -> None: + self._config = config + self._ports = ports + if ports is not None: + self._n = len(ports) + else: + self._n = n + self._instances: list[BalatroInstance] = [] + self._infos: list[InstanceInfo] = [] + self._started = False + self._session_name: str | None = None + + @property + def session_name(self) -> str | None: + """Session directory name (timestamp), available after start().""" + return self._session_name + + @property + def n(self) -> int: + """Number of instances in the pool.""" + return self._n + + @property + def is_started(self) -> bool: + """Whether the pool has been started.""" + return self._started + + @property + def instances(self) -> list[InstanceInfo]: + """List of InstanceInfo for started instances.""" + return list(self._infos) + + async def start(self) -> None: + """Allocate ports, spawn instances, health-check, clean up on failure.""" + if self._started: + raise RuntimeError("Pool already started") + + # Allocate ports + if self._ports is not None: + ports = self._ports + else: + from balatrobot.state import allocate_ports + + ports = allocate_ports(self._n) + + # Generate shared session directory name (timestamp) + self._session_name = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + + # Create and start instances + self._instances = [] + self._infos = [] + + try: + for port in ports: + inst = BalatroInstance( + self._config, + session_name=self._session_name, + port=port, + ) + await inst.start() + self._instances.append(inst) + self._infos.append( + InstanceInfo( + host=self._config.host, port=port, log_path=inst.log_path + ) + ) + except Exception: + # Fail-fast: stop all instances that were started + await self._stop_all() + raise + + self._started = True + + async def stop(self) -> None: + """Stop all instances concurrently.""" + if not self._started: + return + await self._stop_all() + + async def _stop_all(self) -> None: + """Internal: stop all instances concurrently.""" + if not self._instances: + return + await asyncio.gather( + *(inst.stop() for inst in self._instances), + return_exceptions=True, + ) + self._instances = [] + self._infos = [] + self._started = False + + def check_alive(self) -> None: + """Check all instances are still running. + + Raises InstanceDiedError from the first dead instance found. + """ + for inst in self._instances: + inst.check_alive() diff --git a/src/balatrobot/state.py b/src/balatrobot/state.py new file mode 100644 index 00000000..37e87ae1 --- /dev/null +++ b/src/balatrobot/state.py @@ -0,0 +1,271 @@ +"""StateFile — static utilities for BalatroPool state-file discovery. + +Provides read / write / delete / resolve helpers for the JSON state file +that enables discovery of running BalatroPool instances by CLI tools and +test fixtures. +""" + +import json +import os +import socket +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from platformdirs import user_state_dir + +from balatrobot.instance import InstanceInfo + +# --------------------------------------------------------------------------- +# Port allocation +# --------------------------------------------------------------------------- + + +def _allocate_port() -> int: + """Allocate a single free port via bind(0).""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def allocate_ports(n: int) -> list[int]: + """Allocate n free ports. + + Uses bind(0) to find available ports. There is a small TOCTOU window + between allocation and actual use, but this is acceptable for the + pool use case. + """ + return [_allocate_port() for _ in range(n)] + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class StateFileError(Exception): + """Base exception for state file operations.""" + + +class StateFileBusy(StateFileError): + """A live state file already exists (another pool is running).""" + + def __init__(self, path: str | Path, pid: int) -> None: + self.path = str(path) + self.pid = pid + super().__init__(f"State file {path!s} is locked by PID {pid}") + + +class StateFileNotFound(StateFileError): + """No state file found.""" + + def __init__(self, path: str | Path | None = None) -> None: + self.path = str(path) if path is not None else None + super().__init__( + f"No state file found at {path!s}" if path else "No state file found" + ) + + +class InstanceNotFoundError(StateFileError): + """Requested instance index or host:port not in state file.""" + + def __init__(self, index: int | None = None, total: int | None = None) -> None: + self.index = index + self.total = total + msg_parts = [] + if index is not None: + msg_parts.append(f"index={index}") + if total is not None: + msg_parts.append(f"total={total}") + super().__init__(f"Instance not found ({', '.join(msg_parts)})") + + +# --------------------------------------------------------------------------- +# StateFile +# --------------------------------------------------------------------------- + +_DEFAULT_FILENAME = "state.json" +_ENV_STATE_DIR = "BALATROBOT_STATE_DIR" + + +def default_state_path() -> Path: + """Resolve the default state file path. + + Uses ``BALATROBOT_STATE_DIR`` env var if set, otherwise falls back + to ``platformdirs.user_state_dir("balatrobot")``. + """ + env_dir = os.environ.get(_ENV_STATE_DIR) + if env_dir: + base = Path(env_dir) + else: + base = Path(user_state_dir("balatrobot")) + return base / _DEFAULT_FILENAME + + +def _is_pid_alive(pid: int) -> bool: + """Check whether *pid* is a running process.""" + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError, OSError): + return False + + +class StateFile: + """Static utilities for reading, writing, and resolving state files. + + All methods are static. The state file is a JSON document that enables + discovery of running BalatroPool instances by CLI tools and test fixtures. + """ + + # -- Static helpers ----------------------------------------------------- + + @staticmethod + def read(path: Path | None = None) -> dict[str, Any] | None: + """Read and validate a state file. + + Returns ``None`` if the file doesn't exist, contains invalid JSON, + or references a dead PID (in which case the orphan file is deleted). + + Args: + path: Path to read. Defaults to the platform-default path. + """ + state_path = path or default_state_path() + + if not state_path.exists(): + return None + + try: + data = json.loads(state_path.read_text()) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + + pid = data.get("pid") + if pid is not None and not _is_pid_alive(pid): + # Orphan — auto-delete + try: + state_path.unlink() + except OSError: + pass + return None + + return data + + @staticmethod + def resolve( + host: str | None = None, + port: int | None = None, + index: int | None = None, + path: Path | None = None, + ) -> InstanceInfo: + """Discover an instance from the state file. + + Resolution order: + 1. If both *host* and *port* are given, find matching instance. + 2. If *index* is given (or defaults to 0), return that instance. + 3. Raises on missing state file, empty instances, or not found. + + Args: + host: Filter by host. + port: Filter by port. + index: Instance index (0-based). Defaults to 0. + path: State file path override. + + Raises: + StateFileNotFound: No state file or empty instances. + InstanceNotFoundError: No matching instance. + """ + data = StateFile.read(path) + if data is None: + raise StateFileNotFound(path or default_state_path()) + + instances = data.get("instances", []) + if not instances: + raise StateFileNotFound(path or default_state_path()) + + # Explicit host+port lookup + if host is not None and port is not None: + for inst in instances: + if inst["host"] == host and inst["port"] == port: + return InstanceInfo( + host=inst["host"], + port=inst["port"], + log_path=Path(inst["log_path"]) + if inst["log_path"] is not None + else None, + ) + raise InstanceNotFoundError(index=None, total=len(instances)) + + # Index-based lookup (default to 0) + idx = index if index is not None else 0 + if idx < 0 or idx >= len(instances): + raise InstanceNotFoundError(index=idx, total=len(instances)) + + inst = instances[idx] + return InstanceInfo( + host=inst["host"], + port=inst["port"], + log_path=Path(inst["log_path"]) if inst["log_path"] is not None else None, + ) + + # -- Write / Delete ---------------------------------------------------- + + @staticmethod + def write( + path: Path, + pid: int, + instances: list[InstanceInfo], + ) -> None: + """Write a state file atomically. + + Creates parent directories if needed. Uses temp file + ``os.replace`` + for atomicity. + + Args: + path: Destination file path. + pid: Process ID of the server. + instances: List of InstanceInfo to record. + """ + data = { + "pid": pid, + "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "instances": [ + { + "host": info.host, + "port": info.port, + "log_path": str(info.log_path) + if info.log_path is not None + else None, + } + for info in instances + ], + } + # Ensure parent directory exists + path.parent.mkdir(parents=True, exist_ok=True) + + # Atomic write: write to temp file, then rename + fd, tmp_path = tempfile.mkstemp( + dir=str(path.parent), + prefix=".state-", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f) + os.replace(tmp_path, str(path)) + except BaseException: + # Clean up temp file on error + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + @staticmethod + def delete(path: Path) -> None: + """Delete a state file. Silent if the file doesn't exist.""" + try: + path.unlink(missing_ok=True) + except OSError: + pass diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua index c6642a15..845cb875 100644 --- a/src/lua/core/dispatcher.lua +++ b/src/lua/core/dispatcher.lua @@ -6,14 +6,14 @@ ---@type Validator local Validator = assert(SMODS.load_file("src/lua/core/validator.lua"))() ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() local socket = require("socket") ---@type table<integer, string>? local STATE_NAME_CACHE = nil ----@param state_value integer +---@param state_value integer|string ---@return string local function get_state_name(state_value) if not STATE_NAME_CACHE then @@ -98,7 +98,7 @@ function BB_DISPATCHER.load_endpoints(endpoint_files) end loaded_count = loaded_count + 1 end - sendDebugMessage("Loaded " .. loaded_count .. " endpoint(s)", "BB.DISPATCHER") + sendInfoMessage("Loaded " .. loaded_count .. " endpoint(s)", "BB.DISPATCHER") return true end @@ -113,7 +113,7 @@ function BB_DISPATCHER.init(server_module, endpoint_files) sendErrorMessage("Dispatcher initialization failed: " .. err, "BB.DISPATCHER") return false end - sendDebugMessage("Dispatcher initialized successfully", "BB.DISPATCHER") + sendInfoMessage("Dispatcher initialized", "BB.DISPATCHER") return true end @@ -121,7 +121,7 @@ end ---@param error_code string function BB_DISPATCHER.send_error(message, error_code) if not BB_DISPATCHER.Server then - sendDebugMessage("Cannot send error - Server not initialized", "BB.DISPATCHER") + sendErrorMessage("Cannot send error - Server not initialized", "BB.DISPATCHER") return end BB_DISPATCHER.Server.send_response({ @@ -132,7 +132,7 @@ end ---@param request Request.Server function BB_DISPATCHER.dispatch(request) - -- Trigger render for this frame if render_on_api mode is enabled + -- Trigger render for this frame if ondemand mode is enabled if BB_RENDER ~= nil then BB_RENDER = true end @@ -168,7 +168,7 @@ function BB_DISPATCHER.dispatch(request) -- Log incoming request with params local start_time = socket.gettime() - sendDebugMessage(request.method .. BB_LOGGER.serialize_params(params), "BB.REQUEST") + sendDebugMessage(request.method .. BB_FORMAT.serialize_params(params), "BB.REQUEST") -- TIER 2: Schema Validation local valid, err_msg, err_code = Validator.validate(params, endpoint.schema) @@ -213,13 +213,13 @@ function BB_DISPATCHER.dispatch(request) local duration_ms = (socket.gettime() - start_time) * 1000 local is_error = response.message ~= nil if is_error then - sendDebugMessage(string.format("%s ERR (%.0fms)", request.method, duration_ms), "BB.RESPONSE") + sendWarnMessage(string.format("%s ERR (%.0fms)", request.method, duration_ms), "BB.RESPONSE") else - sendDebugMessage(string.format("%s OK (%.0fms)", request.method, duration_ms), "BB.RESPONSE") + sendInfoMessage(string.format("%s OK (%.0fms)", request.method, duration_ms), "BB.RESPONSE") end BB_DISPATCHER.Server.send_response(response) else - sendDebugMessage("Cannot send response - Server not initialized", "BB.DISPATCHER") + sendErrorMessage("Cannot send response - Server not initialized", "BB.DISPATCHER") end end local exec_success, exec_error = pcall(function() diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua index cbdeb292..b20c28c3 100644 --- a/src/lua/core/server.lua +++ b/src/lua/core/server.lua @@ -79,6 +79,9 @@ BB_SERVER = { current_request_id = nil, client_state = nil, openrpc_spec = nil, + -- JSONL recording + req_file = nil, + res_file = nil, } --- Create fresh client state for HTTP parsing @@ -124,13 +127,33 @@ function BB_SERVER.init() if spec_file then BB_SERVER.openrpc_spec = spec_file:read("*a") spec_file:close() - sendDebugMessage("Loaded OpenRPC spec from " .. spec_path, "BB.SERVER") + sendInfoMessage("Loaded OpenRPC spec from " .. spec_path, "BB.SERVER") else sendWarnMessage("OpenRPC spec not found at " .. spec_path, "BB.SERVER") BB_SERVER.openrpc_spec = '{"error": "OpenRPC spec not found"}' end - sendDebugMessage("HTTP server listening on http://" .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") + sendInfoMessage("HTTP server listening on http://" .. BB_SERVER.host .. ":" .. BB_SERVER.port, "BB.SERVER") + + -- Open JSONL recording files if BALATROBOT_PATH_LOGS is set + local logs_path = os.getenv("BALATROBOT_PATH_LOGS") + if logs_path and logs_path ~= "" then + local req_path = logs_path .. "/" .. BB_SERVER.port .. ".req.jsonl" + local res_path = logs_path .. "/" .. BB_SERVER.port .. ".res.jsonl" + local rf, rf_err = io.open(req_path, "a") + if rf then + BB_SERVER.req_file = rf + else + sendDebugMessage("Cannot open req JSONL: " .. tostring(rf_err), "BB.SERVER") + end + local sf, sf_err = io.open(res_path, "a") + if sf then + BB_SERVER.res_file = sf + else + sendDebugMessage("Cannot open res JSONL: " .. tostring(sf_err), "BB.SERVER") + end + end + return true end @@ -250,7 +273,7 @@ local function send_raw(response_str) local _, err = BB_SERVER.client_socket:send(response_str) if err then - sendDebugMessage("Failed to send response: " .. err, "BB.SERVER") + sendErrorMessage("Failed to send response: " .. err, "BB.SERVER") return false end return true @@ -340,6 +363,12 @@ local function handle_jsonrpc(body, dispatcher) BB_SERVER.current_request_id = parsed.id + -- Record request to JSONL + if BB_SERVER.req_file then + BB_SERVER.req_file:write(body .. "\n") + BB_SERVER.req_file:flush() + end + -- Dispatch to endpoint if dispatcher and dispatcher.dispatch then dispatcher.dispatch(parsed) @@ -412,10 +441,16 @@ function BB_SERVER.send_response(response) local success, json_str = pcall(json.encode, wrapped) if not success then - sendDebugMessage("Failed to encode response: " .. tostring(json_str), "BB.SERVER") + sendErrorMessage("Failed to encode response: " .. tostring(json_str), "BB.SERVER") return false end + -- Record response to JSONL + if BB_SERVER.res_file then + BB_SERVER.res_file:write(json_str .. "\n") + BB_SERVER.res_file:flush() + end + -- Send HTTP response local http_response = format_http_response(200, "OK", json_str) local sent = send_raw(http_response) @@ -463,9 +498,19 @@ end function BB_SERVER.close() close_client() + -- Close JSONL recording files + if BB_SERVER.req_file then + BB_SERVER.req_file:close() + BB_SERVER.req_file = nil + end + if BB_SERVER.res_file then + BB_SERVER.res_file:close() + BB_SERVER.res_file = nil + end + if BB_SERVER.server_socket then BB_SERVER.server_socket:close() BB_SERVER.server_socket = nil - sendDebugMessage("Server closed", "BB.SERVER") + sendInfoMessage("Server closed", "BB.SERVER") end end diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua index 0bee6a21..5a42bd1a 100644 --- a/src/lua/endpoints/add.lua +++ b/src/lua/endpoints/add.lua @@ -6,8 +6,8 @@ ---@class Request.Endpoint.Add.Params ---@field key Card.Key The card key to add (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards) ----@field seal Card.Modifier.Seal? The card seal to apply (only for playing cards) ----@field edition Card.Modifier.Edition? The card edition to apply (jokers, playing cards and NEGATIVE consumables) +---@field seal Card.Modifier.Seal? Seal type from G.P_SEALS (e.g. Red, Blue, Gold, Purple) - only valid for playing cards +---@field edition Card.Modifier.Edition? The card edition to apply (jokers, playing cards and e_negative consumables) ---@field enhancement Card.Modifier.Enhancement? The card enhancement to apply (playing cards) ---@field eternal boolean? If true, the card will be eternal (jokers only) ---@field perishable integer? The card will be perishable for this many rounds (jokers only, must be >= 1) @@ -42,34 +42,6 @@ local RANK_MAP = { A = "Ace", } --- Seal conversion table -local SEAL_MAP = { - RED = "Red", - BLUE = "Blue", - GOLD = "Gold", - PURPLE = "Purple", -} - --- Edition conversion table -local EDITION_MAP = { - HOLO = "e_holo", - FOIL = "e_foil", - POLYCHROME = "e_polychrome", - NEGATIVE = "e_negative", -} - --- Enhancement conversion table -local ENHANCEMENT_MAP = { - BONUS = "m_bonus", - MULT = "m_mult", - WILD = "m_wild", - GLASS = "m_glass", - STEEL = "m_steel", - STONE = "m_stone", - GOLD = "m_gold", - LUCKY = "m_lucky", -} - ---Detect card type based on key prefix or pattern ---@param key string The card key ---@return string|nil card_type The detected card type or nil if invalid @@ -121,28 +93,28 @@ return { name = "add", - description = "Add a new card to the game (joker, consumable, voucher, or playing card)", + description = "Add a new card to the game (joker, consumable, voucher, pack, or playing card)", schema = { key = { type = "string", required = true, - description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, SUIT_RANK for playing cards like H_A)", + description = "Card key (j_* for jokers, c_* for consumables, v_* for vouchers, p_* for packs, SUIT_RANK for playing cards like H_A)", }, seal = { type = "string", required = false, - description = "Seal type (RED, BLUE, GOLD, PURPLE) - only valid for playing cards", + description = "Seal type from G.P_SEALS (e.g. Red, Blue, Gold, Purple) - only valid for playing cards", }, edition = { type = "string", required = false, - description = "Edition type (HOLO, FOIL, POLYCHROME, NEGATIVE) - valid for jokers, playing cards, and consumables (consumables: NEGATIVE only)", + description = "Edition key (e_foil, e_holo, e_polychrome, e_negative) - valid for jokers, playing cards, and consumables (consumables: e_negative only)", }, enhancement = { type = "string", required = false, - description = "Enhancement type (BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, LUCKY) - only valid for playing cards", + description = "Enhancement key (m_bonus, m_mult, m_wild, m_glass, m_steel, m_stone, m_gold, m_lucky) - only valid for playing cards", }, eternal = { type = "boolean", @@ -166,14 +138,14 @@ return { ---@param args Request.Endpoint.Add.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init add()", "BB.ENDPOINTS") + sendDebugMessage("add()", "BB.ENDPOINTS") -- Detect card type local card_type = detect_card_type(args.key) if not card_type then send_response({ - message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + message = "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), pack (p_*), or playing card (SUIT_RANK)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -248,17 +220,17 @@ return { return end - -- Validate and convert seal value + -- Validate seal value local seal_value = nil if args.seal then - seal_value = SEAL_MAP[args.seal] - if not seal_value then + if args.seal ~= "Red" and args.seal ~= "Blue" and args.seal ~= "Gold" and args.seal ~= "Purple" then send_response({ - message = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", + message = "Invalid seal value. Expected a Seal key from G.P_SEALS (e.g. Red, Blue)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + seal_value = args.seal end -- Validate edition parameter is only for jokers, playing cards, or consumables @@ -270,10 +242,10 @@ return { return end - -- Special validation: consumables can only have NEGATIVE edition - if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then + -- Special validation: consumables can only have e_negative edition + if args.edition and card_type == "consumable" and args.edition ~= "e_negative" then send_response({ - message = "Consumables can only have NEGATIVE edition", + message = "Consumables can only have e_negative edition", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -282,14 +254,14 @@ return { -- Validate and convert edition value local edition_value = nil if args.edition then - edition_value = EDITION_MAP[args.edition] - if not edition_value then + if args.edition:sub(1, 2) ~= "e_" then send_response({ - message = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", + message = "Expected an e_* edition key (e.g. e_foil, e_holo)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + edition_value = args.edition end -- Validate enhancement parameter is only for playing cards @@ -301,17 +273,21 @@ return { return end - -- Validate and convert enhancement value + -- Validate enhancement value local enhancement_value = nil if args.enhancement then - enhancement_value = ENHANCEMENT_MAP[args.enhancement] - if not enhancement_value then + if + type(args.enhancement) ~= "string" + or args.enhancement:sub(1, 2) ~= "m_" + or not G.P_CENTERS[args.enhancement] + then send_response({ - message = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", + message = "Expected an m_* enhancement key (e.g. m_bonus, m_mult)", name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + enhancement_value = args.enhancement end -- Validate eternal parameter is only for jokers @@ -378,12 +354,6 @@ return { if enhancement_value then params.enhancement = enhancement_value end - elseif card_type == "voucher" then - params = { - key = args.key, - area = G.shop_vouchers, - skip_materialize = true, - } else -- For jokers and consumables - just pass the key params = { @@ -414,6 +384,8 @@ return { end end + sendInfoMessage(string.format("Adding %s '%s'", card_type, args.key), "BB.ENDPOINTS") + -- Track initial state for verification local initial_joker_count = G.jokers and G.jokers.config and G.jokers.config.card_count or 0 local initial_consumable_count = G.consumeables and G.consumeables.config and G.consumeables.config.card_count or 0 @@ -421,14 +393,15 @@ return { local initial_hand_count = G.hand and G.hand.config and G.hand.config.card_count or 0 local initial_pack_count = G.shop_booster and G.shop_booster.config and G.shop_booster.config.card_count or 0 - sendDebugMessage("Initial voucher count: " .. initial_voucher_count, "BB.ENDPOINTS") - -- Call SMODS function with error handling local success, result if card_type == "pack" then -- Packs use dedicated SMODS function success, result = pcall(SMODS.add_booster_to_shop, args.key) + elseif card_type == "voucher" then + -- Vouchers use dedicated SMODS function + success, result = pcall(SMODS.add_voucher_to_shop, args.key) else -- Other cards use SMODS.add_card success, result = pcall(SMODS.add_card, params) @@ -447,8 +420,6 @@ return { result.ability.perish_tally = args.perishable end - sendDebugMessage("SMODS.add_card called for: " .. args.key .. " (" .. card_type .. ")", "BB.ENDPOINTS") - -- Wait for card addition to complete with event-based verification G.E_MANAGER:add_event(Event({ trigger = "condition", @@ -487,7 +458,7 @@ return { -- All conditions must be met if added and state_stable and valid_state then - sendDebugMessage("Card added successfully: " .. args.key, "BB.ENDPOINTS") + sendDebugMessage("add() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua index 77e42963..c0369c9e 100644 --- a/src/lua/endpoints/buy.lua +++ b/src/lua/endpoints/buy.lua @@ -43,7 +43,7 @@ return { ---@param args Request.Endpoint.Buy.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init buy()", "BB.ENDPOINTS") + sendDebugMessage("buy()", "BB.ENDPOINTS") local gamestate = BB_GAMESTATE.get_gamestate() local area local pos @@ -86,11 +86,11 @@ return { if #area.cards == 0 then local msg if args.card then - msg = "No jokers/consumables/cards in the shop. Reroll to restock the shop" + msg = "No jokers/consumables/cards in the shop. Use `reroll` to restock the shop." elseif args.voucher then - msg = "No vouchers to redeem. Defeat boss blind to restock" + msg = "No vouchers to redeem. Defeat boss blind to restock." elseif args.pack then - msg = "No packs to open" + msg = "No packs to open. Use `next_round` to advance to the next blind and restock the shop." end send_response({ message = msg, @@ -136,7 +136,8 @@ return { message = "Cannot purchase joker card, joker slots are full. Current: " .. gamestate.jokers.count .. ", Limit: " - .. gamestate.jokers.limit, + .. gamestate.jokers.limit + .. ". Sell a joker using `sell` to free a slot.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -150,7 +151,8 @@ return { message = "Cannot purchase consumable card, consumable slots are full. Current: " .. gamestate.consumables.count .. ", Limit: " - .. gamestate.consumables.limit, + .. gamestate.consumables.limit + .. ". Use `use` to activate a consumable or `sell` to remove one.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -194,7 +196,7 @@ return { -- Log what we're buying local item_name = card.name or (card.ability and card.ability.name) or card.label or "Unknown" local item_type = args.voucher and "Voucher" or args.pack and "Booster" or card.set or "item" - sendDebugMessage(string.format("Buying %s '%s' for $%d", item_type, item_name, card.cost.buy), "BB.ENDPOINTS") + sendInfoMessage(string.format("Buying %s '%s' for $%d", item_type, item_name, card.cost.buy), "BB.ENDPOINTS") -- Use appropriate function: use_card for vouchers, buy_from_shop for others if args.voucher or args.pack then @@ -246,17 +248,24 @@ return { and G.STATE == G.STATES.SMODS_BOOSTER_OPENED ) if money_deducted and pack_ready then - -- Check if this pack type needs hand (Arcana/Spectral packs) - local pack_key = G.pack_cards.cards[1].ability and G.pack_cards.cards[1].ability.set - local needs_hand = pack_key == "Tarot" or pack_key == "Spectral" + -- Check if this pack type needs hand cards (Arcana/Spectral packs) + -- Use the booster's own draw_hand flag — the authoritative source. + -- Don't infer from card set: Black Hole (set=Spectral) can appear + -- in Celestial packs via soul roll, causing false positives. + local needs_hand = SMODS.OPENED_BOOSTER + and SMODS.OPENED_BOOSTER.config + and SMODS.OPENED_BOOSTER.config.center + and SMODS.OPENED_BOOSTER.config.center.draw_hand == true if needs_hand then -- Wait for hand to be fully loaded and positioned local hand_limit = G.hand and G.hand.config and G.hand.config.card_limit or 8 + local deck_size = G.deck and G.deck.config and G.deck.config.card_count or 52 + local expected_hand_size = math.min(deck_size, hand_limit) local hand_ready = G.hand and not G.hand.REMOVED and G.hand.cards - and #G.hand.cards == hand_limit + and #G.hand.cards >= expected_hand_size and G.hand.T and G.hand.T.x local cards_positioned = hand_ready and G.hand.cards[1] and G.hand.cards[1].T and G.hand.cards[1].T.x @@ -268,7 +277,7 @@ return { end if done then - sendDebugMessage("Return buy()", "BB.ENDPOINTS") + sendDebugMessage("buy() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua index 03e7ee64..c5fed02c 100644 --- a/src/lua/endpoints/cash_out.lua +++ b/src/lua/endpoints/cash_out.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.CashOut.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init cash_out()", "BB.ENDPOINTS") + sendDebugMessage("cash_out()", "BB.ENDPOINTS") G.FUNCS.cash_out({ config = {} }) local num_items = function(area) @@ -48,7 +48,7 @@ return { if G.STATE == G.STATES.SHOP and G.STATE_COMPLETE then done = num_items(G.shop_booster) > 0 or num_items(G.shop_jokers) > 0 or num_items(G.shop_vouchers) > 0 if done then - sendDebugMessage("Return cash_out() - reached SHOP state", "BB.ENDPOINTS") + sendDebugMessage("cash_out() → SHOP", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return done end diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua index 77316976..0503714f 100644 --- a/src/lua/endpoints/discard.lua +++ b/src/lua/endpoints/discard.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/discard.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Discard Endpoint Params @@ -35,7 +35,7 @@ return { ---@param args Request.Endpoint.Discard.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init discard()", "BB.ENDPOINTS") + sendDebugMessage("discard()", "BB.ENDPOINTS") if #args.cards == 0 then send_response({ message = "Must provide at least one card to discard", @@ -46,7 +46,7 @@ return { if G.GAME.current_round.discards_left <= 0 then send_response({ - message = "No discards left", + message = "No discards left. Play cards using `play` instead.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -54,7 +54,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards", + message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -70,20 +70,50 @@ return { end end - -- NOTE: Clear any existing highlights before selecting new cards - -- prevent state pollution. This is a bit of a hack but could interfere - -- with Boss Blind like Cerulean Bell. - G.hand:unhighlight_all() + -- Validate forced-selection cards (e.g. Cerulean Bell boss blind) + -- If any card has forced_selection, it MUST be included in the discard + for i = 1, #G.hand.cards do + local card = G.hand.cards[i] + if card.ability and card.ability.forced_selection then + local included = false + for _, card_index in ipairs(args.cards) do + if card_index + 1 == i then + included = true + break + end + end + if not included then + send_response({ + message = "Card at index " + .. (i - 1) + .. " is forced-selected by the boss blind. Include it in your discard.", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + end + end + -- Clear non-forced highlights only (preserves forced-selection cards) + for i = #G.hand.highlighted, 1, -1 do + if not G.hand.highlighted[i].ability.forced_selection then + G.hand.highlighted[i]:highlight(false) + table.remove(G.hand.highlighted, i) + end + end + + -- Click only cards not already highlighted for _, card_index in ipairs(args.cards) do - G.hand.cards[card_index + 1]:click() + if not G.hand.cards[card_index + 1].highlighted then + G.hand.cards[card_index + 1]:click() + end end -- Log the cards being discarded - local card_str = BB_LOGGER.format_playing_cards(G.hand.cards, args.cards) + local card_str = BB_FORMAT.format_playing_cards(G.hand.cards, args.cards) local remaining = G.GAME.current_round.discards_left - 1 - sendDebugMessage( - string.format("Discarding %d cards: %s (%d discards left)", #args.cards, card_str, remaining), + sendInfoMessage( + string.format("Discarding %d cards: %s (%d left)", #args.cards, card_str, remaining), "BB.ENDPOINTS" ) @@ -107,7 +137,7 @@ return { end if draw_to_hand and G.buttons and G.STATE == G.STATES.SELECTING_HAND then - sendDebugMessage("Return discard()", "BB.ENDPOINTS") + sendDebugMessage("discard() → SELECTING_HAND", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua index 7d88100c..2254f495 100644 --- a/src/lua/endpoints/gamestate.lua +++ b/src/lua/endpoints/gamestate.lua @@ -24,9 +24,9 @@ return { ---@param _ Request.Endpoint.Gamestate.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init gamestate()", "BB.ENDPOINTS") + sendDebugMessage("gamestate()", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() - sendDebugMessage("Return gamestate()", "BB.ENDPOINTS") + sendDebugMessage("gamestate() → ok", "BB.ENDPOINTS") send_response(state_data) end, } diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua index f43b4129..07c7032e 100644 --- a/src/lua/endpoints/health.lua +++ b/src/lua/endpoints/health.lua @@ -24,8 +24,8 @@ return { ---@param _ Request.Endpoint.Health.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init health()", "BB.ENDPOINTS") - sendDebugMessage("Return health()", "BB.ENDPOINTS") + sendDebugMessage("health()", "BB.ENDPOINTS") + sendDebugMessage("health() → ok", "BB.ENDPOINTS") send_response({ status = "ok", }) diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua index e9276a5c..f2951056 100644 --- a/src/lua/endpoints/load.lua +++ b/src/lua/endpoints/load.lua @@ -37,10 +37,11 @@ return { ---@param args Request.Endpoint.Load.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init load()", "BB.ENDPOINTS") + sendDebugMessage("load()", "BB.ENDPOINTS") local path = args.path -- Read file using nativefs + sendInfoMessage("Loading from " .. path, "BB.ENDPOINTS") -- NOTE: We intentionally skip nativefs.getInfo() and go straight to -- nativefs.read(). On Proton/Wine, getInfo() uses PHYSFS_mount which -- cannot resolve Linux absolute paths, but read() goes through fopen() @@ -71,7 +72,7 @@ return { -- Load using game's built-in functions G:delete_run() - G.SAVED_GAME = get_compressed(temp_filename) ---@diagnostic disable-line: undefined-global + G.SAVED_GAME = get_compressed(temp_filename) if G.SAVED_GAME == nil then send_response({ @@ -122,7 +123,7 @@ return { func = function() local done = false - if not G.STATE_COMPLETE or G.CONTROLLER.locked then + if not G.STATE_COMPLETE or G.CONTROLLER.locked or (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) then return false end @@ -153,7 +154,7 @@ return { end if done then - sendDebugMessage("Return load() - loaded from " .. path, "BB.ENDPOINTS") + sendDebugMessage("load() → ok", "BB.ENDPOINTS") send_response({ success = true, path = path, diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua index daade393..3bc40a2f 100644 --- a/src/lua/endpoints/menu.lua +++ b/src/lua/endpoints/menu.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.Menu.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init menu()", "BB.ENDPOINTS") + sendDebugMessage("menu()", "BB.ENDPOINTS") if G.STATE ~= G.STATES.MENU then G.FUNCS.go_to_menu({}) end @@ -38,7 +38,7 @@ return { local done = G.STATE == G.STATES.MENU and G.MAIN_MENU_UI if done then - sendDebugMessage("Return menu()", "BB.ENDPOINTS") + sendDebugMessage("menu() → MENU", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua index a310bbed..d418ebf6 100644 --- a/src/lua/endpoints/next_round.lua +++ b/src/lua/endpoints/next_round.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.NextRound.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init next_round()", "BB.ENDPOINTS") + sendDebugMessage("next_round()", "BB.ENDPOINTS") G.FUNCS.toggle_shop({}) -- Wait for BLIND_SELECT state after leaving shop @@ -51,7 +51,7 @@ return { return false end - sendDebugMessage("Return next_round() - reached BLIND_SELECT state", "BB.ENDPOINTS") + sendDebugMessage("next_round() → BLIND_SELECT", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end, diff --git a/src/lua/endpoints/pack.lua b/src/lua/endpoints/pack.lua index d60efa80..1cdef72c 100644 --- a/src/lua/endpoints/pack.lua +++ b/src/lua/endpoints/pack.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/pack.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Pack Select Endpoint Params @@ -83,7 +83,7 @@ return { ---@param args Request.Endpoint.Pack.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init pack()", "BB.ENDPOINTS") + sendDebugMessage("pack()", "BB.ENDPOINTS") -- Validate that exactly one of card or skip is provided local set = 0 @@ -113,7 +113,7 @@ return { -- Validate pack_cards exists if not G.pack_cards or G.pack_cards.REMOVED then send_response({ - message = "No pack is currently open", + message = "No pack is currently open. Use `buy` with `pack` parameter to buy and open a pack.", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -144,7 +144,8 @@ return { message = "Cannot select joker, joker slots are full. Current: " .. joker_count .. ", Limit: " - .. joker_limit, + .. joker_limit + .. ". Sell a joker using `sell` to free a slot.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -160,7 +161,11 @@ return { local joker_count = G.jokers and G.jokers.config and G.jokers.config.card_count or 0 if joker_count == 0 then send_response({ - message = string.format("Card '%s' requires at least 1 joker. Current: %d", card_key, joker_count), + message = string.format( + "Card '%s' requires at least 1 joker. Current: %d. Ensure you have enough jokers before selecting this card.", + card_key, + joker_count + ), name = BB_ERROR_NAMES.NOT_ALLOWED, }) return true @@ -173,14 +178,14 @@ return { local msg if req.min == req.max then msg = string.format( - "Card '%s' requires exactly %d target card(s). Provided: %d", + "Card '%s' requires exactly %d target card(s). Provided: %d. Ensure you have the required targets before selecting.", card_key, req.min, target_count ) else msg = string.format( - "Card '%s' requires %d-%d target card(s). Provided: %d", + "Card '%s' requires %d-%d target card(s). Provided: %d. Ensure you have the required targets before selecting.", card_key, req.min, req.max, @@ -221,13 +226,10 @@ return { local card_name = card.ability and card.ability.name or "Unknown" local card_set = card.ability and card.ability.set or card.set or "card" if args.targets and #args.targets > 0 then - local targets = BB_LOGGER.format_playing_cards(G.hand.cards, args.targets) - sendDebugMessage( - string.format("Pack: selecting %s '%s' targeting: %s", card_set, card_name, targets), - "BB.ENDPOINTS" - ) + local targets = BB_FORMAT.format_playing_cards(G.hand.cards, args.targets) + sendInfoMessage(string.format("Selecting %s '%s' on: %s", card_set, card_name, targets), "BB.ENDPOINTS") else - sendDebugMessage(string.format("Pack: selecting %s '%s'", card_set, card_name), "BB.ENDPOINTS") + sendInfoMessage(string.format("Selecting %s '%s'", card_set, card_name), "BB.ENDPOINTS") end -- Select the card by calling use_card @@ -255,17 +257,17 @@ return { and G.STATE == G.STATES.SMODS_BOOSTER_OPENED if pack_stable then - sendDebugMessage("Return pack() after selection (more choices remain)", "BB.ENDPOINTS") + sendDebugMessage("pack() → selected (more choices)", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end else - -- Pack closes - wait for return to shop + -- Pack closes - wait for return to shop or blind select local pack_closed = not G.pack_cards or G.pack_cards.REMOVED - local back_to_shop = G.STATE == G.STATES.SHOP + local back_to_shop = G.STATE == G.STATES.SHOP or G.STATE == G.STATES.BLIND_SELECT if pack_closed and back_to_shop then - sendDebugMessage("Return pack() after selection", "BB.ENDPOINTS") + sendDebugMessage("pack() → selected", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end @@ -280,7 +282,7 @@ return { -- Handle skip if args.skip then local pack_count = G.pack_cards.config and G.pack_cards.config.card_count or 0 - sendDebugMessage(string.format("Pack: skipping (%d cards remaining)", pack_count), "BB.ENDPOINTS") + sendInfoMessage(string.format("Skipping pack (%d remaining)", pack_count), "BB.ENDPOINTS") G.FUNCS.skip_booster({}) -- Wait for pack to close and return to shop @@ -289,10 +291,10 @@ return { blocking = false, func = function() local pack_closed = not G.pack_cards or G.pack_cards.REMOVED - local back_to_shop = G.STATE == G.STATES.SHOP + local back_to_shop = G.STATE == G.STATES.SHOP or G.STATE == G.STATES.BLIND_SELECT if pack_closed and back_to_shop then - sendDebugMessage("Return pack() after skip", "BB.ENDPOINTS") + sendDebugMessage("pack() → skipped", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end @@ -303,13 +305,14 @@ return { return end - -- Wait for hand cards to load for Arcana and Spectral packs - local pack_key = G.pack_cards - and G.pack_cards.cards - and G.pack_cards.cards[1] - and G.pack_cards.cards[1].ability - and G.pack_cards.cards[1].ability.set - local needs_hand = pack_key == "Tarot" or pack_key == "Spectral" + -- Wait for hand cards to load for packs that need them (Arcana/Spectral) + -- Use the booster's own draw_hand flag — the authoritative source. + -- Don't infer from card set: Black Hole (set=Spectral) can appear + -- in Celestial packs via soul roll, causing false positives. + local needs_hand = SMODS.OPENED_BOOSTER + and SMODS.OPENED_BOOSTER.config + and SMODS.OPENED_BOOSTER.config.center + and SMODS.OPENED_BOOSTER.config.center.draw_hand == true if needs_hand then -- Wait for hand cards to be fully loaded and positioned diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua index 1b9f0a98..510b34b5 100644 --- a/src/lua/endpoints/play.lua +++ b/src/lua/endpoints/play.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/play.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Play Endpoint Params @@ -35,7 +35,7 @@ return { ---@param args Request.Endpoint.Play.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init play()", "BB.ENDPOINTS") + sendDebugMessage("play()", "BB.ENDPOINTS") if #args.cards == 0 then send_response({ message = "Must provide at least one card to play", @@ -46,7 +46,7 @@ return { if #args.cards > G.hand.config.highlighted_limit then send_response({ - message = "You can only play " .. G.hand.config.highlighted_limit .. " cards", + message = "You can only play " .. G.hand.config.highlighted_limit .. " cards. Provide fewer card indices.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -62,18 +62,46 @@ return { end end - -- NOTE: Clear any existing highlights before selecting new cards - -- prevent state pollution. This is a bit of a hack but could interfere - -- with Boss Blind like Cerulean Bell. - G.hand:unhighlight_all() + -- Validate forced-selection cards (e.g. Cerulean Bell boss blind) + -- If any card has forced_selection, it MUST be included in the play + for i = 1, #G.hand.cards do + local card = G.hand.cards[i] + if card.ability and card.ability.forced_selection then + local included = false + for _, card_index in ipairs(args.cards) do + if card_index + 1 == i then + included = true + break + end + end + if not included then + send_response({ + message = "Card at index " .. (i - 1) .. " is forced-selected by the boss blind. Include it in your play.", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + end + end + + -- Clear non-forced highlights only (preserves forced-selection cards) + for i = #G.hand.highlighted, 1, -1 do + if not G.hand.highlighted[i].ability.forced_selection then + G.hand.highlighted[i]:highlight(false) + table.remove(G.hand.highlighted, i) + end + end + -- Click only cards not already highlighted for _, card_index in ipairs(args.cards) do - G.hand.cards[card_index + 1]:click() + if not G.hand.cards[card_index + 1].highlighted then + G.hand.cards[card_index + 1]:click() + end end -- Log the cards being played - local card_str = BB_LOGGER.format_playing_cards(G.hand.cards, args.cards) - sendDebugMessage(string.format("Playing %d cards: %s", #args.cards, card_str), "BB.ENDPOINTS") + local card_str = BB_FORMAT.format_playing_cards(G.hand.cards, args.cards) + sendInfoMessage(string.format("Playing %d cards: %s", #args.cards, card_str), "BB.ENDPOINTS") ---@diagnostic disable-next-line: undefined-field local play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot) @@ -93,7 +121,7 @@ return { trigger = "condition", blocking = false, blockable = false, - created_on_pause = true, + pause_force = true, func = function() -- State progression: -- Loss: HAND_PLAYED -> NEW_ROUND -> (game paused) -> GAME_OVER @@ -121,12 +149,14 @@ return { return false end - -- Game is won - if G.GAME.won then - sendDebugMessage("Return play() - won", "BB.ENDPOINTS") - local state_data = BB_GAMESTATE.get_gamestate() - send_response(state_data) - return true + -- Game is won: win_game() raises the win overlay and pauses the game. + -- Dismiss it now so the round-eval rows finish building (they are + -- pause-skipped while paused) and endless-mode play stays responsive. + -- The overlay only appears after ROUND_EVAL is entered, so G.round_eval + -- already exists here, and the delayed win events (Jimbo, endless text) + -- guard against a nil G.OVERLAY_MENU. + if G.GAME.won and G.OVERLAY_MENU then + G.FUNCS.exit_overlay_menu() end -- Wait for first scoring row (blind1) to be added to the UI @@ -144,15 +174,15 @@ return { -- Both first and last scoring rows must be present if has_blind1 and has_cash_out_button then + sendDebugMessage(G.GAME.won and "play() → won" or "play() → cash_out", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() - sendDebugMessage("Return play() - cash out", "BB.ENDPOINTS") send_response(state_data) return true end end if draw_to_hand and hand_played and G.buttons and G.STATE == G.STATES.SELECTING_HAND then - sendDebugMessage("Return play() - same round", "BB.ENDPOINTS") + sendDebugMessage("play() → continue", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua index e8ec143f..195448bc 100644 --- a/src/lua/endpoints/rearrange.lua +++ b/src/lua/endpoints/rearrange.lua @@ -46,7 +46,7 @@ return { ---@param args Request.Endpoint.Rearrange.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init rearrange()", "BB.ENDPOINTS") + sendDebugMessage("rearrange()", "BB.ENDPOINTS") -- Validate exactly one parameter is provided local param_count = (args.hand and 1 or 0) + (args.jokers and 1 or 0) + (args.consumables and 1 or 0) if param_count == 0 then @@ -132,10 +132,7 @@ return { -- Log what we're rearranging local order_str = "[" .. table.concat(indices, ",") .. "]" - sendDebugMessage( - string.format("Rearranging %s (%d cards): %s", type_name, #source_array, order_str), - "BB.ENDPOINTS" - ) + sendInfoMessage(string.format("Rearranging %s (%d cards): %s", type_name, #source_array, order_str), "BB.ENDPOINTS") -- Validate permutation: correct length, no duplicates, all indices present -- Check length matches @@ -226,7 +223,7 @@ return { end if done then - sendDebugMessage("Return rearrange()", "BB.ENDPOINTS") + sendDebugMessage("rearrange() → ok", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua index 3759789e..5596d3a0 100644 --- a/src/lua/endpoints/reroll.lua +++ b/src/lua/endpoints/reroll.lua @@ -24,6 +24,8 @@ return { ---@param _ Request.Endpoint.Reroll.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) + sendDebugMessage("reroll()", "BB.ENDPOINTS") + -- Check affordability (accounting for Credit Card joker via bankrupt_at) local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0 local available_money = G.GAME.dollars - G.GAME.bankrupt_at @@ -37,7 +39,7 @@ return { end -- Log reroll with cost and money - sendDebugMessage(string.format("Rerolling shop (cost=$%d, money=$%d)", reroll_cost, G.GAME.dollars), "BB.ENDPOINTS") + sendInfoMessage(string.format("Rerolling shop ($%d, money=$%d)", reroll_cost, G.GAME.dollars), "BB.ENDPOINTS") G.FUNCS.reroll_shop(nil) -- Wait for shop state to confirm reroll completed @@ -47,7 +49,7 @@ return { func = function() local done = G.STATE == G.STATES.SHOP if done then - sendDebugMessage(string.format("Return reroll() money=$%d", G.GAME.dollars), "BB.ENDPOINTS") + sendDebugMessage("reroll() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) end return done diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua index 49ac50f0..e7f5af20 100644 --- a/src/lua/endpoints/save.lua +++ b/src/lua/endpoints/save.lua @@ -53,7 +53,7 @@ return { ---@param args Request.Endpoint.Save.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init save()", "BB.ENDPOINTS") + sendDebugMessage("save()", "BB.ENDPOINTS") local path = args.path -- Validate we're in a run @@ -66,10 +66,11 @@ return { end -- Call save_run() and use compress_and_save - save_run() ---@diagnostic disable-line: undefined-global + sendInfoMessage("Saving to " .. path, "BB.ENDPOINTS") + save_run() local temp_filename = "balatrobot_temp_save_" .. BB_SETTINGS.port .. ".jkr" - compress_and_save(temp_filename, G.ARGS.save_run) ---@diagnostic disable-line: undefined-global + compress_and_save(temp_filename, G.ARGS.save_run) -- Read from temp and write to target path using nativefs local save_dir = love.filesystem.getSaveDirectory() @@ -97,7 +98,7 @@ return { -- Clean up love.filesystem.remove(temp_filename) - sendDebugMessage("Return save() - saved to " .. path, "BB.ENDPOINTS") + sendDebugMessage("save() → ok", "BB.ENDPOINTS") send_response({ success = true, path = path, diff --git a/src/lua/endpoints/screenshot.lua b/src/lua/endpoints/screenshot.lua index 8be47165..a579a796 100644 --- a/src/lua/endpoints/screenshot.lua +++ b/src/lua/endpoints/screenshot.lua @@ -37,7 +37,7 @@ return { ---@param args Request.Endpoint.Screenshot.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init screenshot()", "BB.ENDPOINTS") + sendDebugMessage("screenshot()", "BB.ENDPOINTS") local path = args.path love.graphics.captureScreenshot(function(imagedata) @@ -56,6 +56,7 @@ return { local png_data = filedata:getString() -- Write to target path using nativefs + sendInfoMessage("Screenshot → " .. path, "BB.ENDPOINTS") local write_success = nativefs.write(path, png_data) if not write_success then send_response({ @@ -65,7 +66,7 @@ return { return end - sendDebugMessage("Return screenshot() - saved to " .. path, "BB.ENDPOINTS") + sendDebugMessage("screenshot() → ok", "BB.ENDPOINTS") send_response({ success = true, path = path, diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua index b250220f..9c0f193e 100644 --- a/src/lua/endpoints/select.lua +++ b/src/lua/endpoints/select.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.Select.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init select()", "BB.ENDPOINTS") + sendDebugMessage("select()", "BB.ENDPOINTS") -- Get current blind and its UI element local current_blind = G.GAME.blind_on_deck assert(current_blind ~= nil, "select() called with no blind on deck") @@ -37,10 +37,7 @@ return { local blind_info = BB_GAMESTATE.get_blinds_info()[string.lower(current_blind)] local blind_name = blind_info and blind_info.name or current_blind local chips = blind_info and blind_info.chips or "?" - sendDebugMessage( - string.format("Selecting %s (%s), chips required: %s", current_blind, blind_name, tostring(chips)), - "BB.ENDPOINTS" - ) + sendInfoMessage(string.format("Selecting %s blind (%s chips)", current_blind, tostring(chips)), "BB.ENDPOINTS") -- Execute blind selection G.FUNCS.select_blind(select_button) @@ -52,7 +49,7 @@ return { func = function() local done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil if done then - sendDebugMessage("Return select()", "BB.ENDPOINTS") + sendDebugMessage("select() → SELECTING_HAND", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua index 5d112222..6b334038 100644 --- a/src/lua/endpoints/sell.lua +++ b/src/lua/endpoints/sell.lua @@ -32,12 +32,12 @@ return { }, }, - requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP }, + requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.SMODS_BOOSTER_OPENED }, ---@param args Request.Endpoint.Sell.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init sell()", "BB.ENDPOINTS") + sendDebugMessage("sell()", "BB.ENDPOINTS") -- Validate exactly one parameter is provided local param_count = (args.joker and 1 or 0) + (args.consumable and 1 or 0) @@ -55,6 +55,22 @@ return { return end + -- If in SMODS_BOOSTER_OPENED, verify it's a Buffoon pack (contains Jokers) + if G.STATE == G.STATES.SMODS_BOOSTER_OPENED then + local pack_set = G.pack_cards + and G.pack_cards.cards + and G.pack_cards.cards[1] + and G.pack_cards.cards[1].ability + and G.pack_cards.cards[1].ability.set + if pack_set ~= "Joker" then + send_response({ + message = "Can only sell jokers when a Buffoon pack is open", + name = BB_ERROR_NAMES.NOT_ALLOWED, + }) + return + end + end + -- Determine which type to sell and validate existence local source_array, pos, sell_type @@ -96,15 +112,12 @@ return { local card = source_array[pos] -- Track initial state for completion verification - local area = sell_type == "joker" and G.jokers or G.consumeables - local initial_count = area.config.card_count local initial_money = G.GAME.dollars local expected_money = initial_money + card.sell_cost - local card_id = card.sort_id -- Log what we're selling local item_name = card.ability and card.ability.name or "Unknown" - sendDebugMessage(string.format("Selling %s '%s' for $%d", sell_type, item_name, card.sell_cost), "BB.ENDPOINTS") + sendInfoMessage(string.format("Selling %s '%s' for $%d", sell_type, item_name, card.sell_cost), "BB.ENDPOINTS") -- Create mock UI element for G.FUNCS.sell_card local mock_element = { @@ -116,39 +129,29 @@ return { -- Call the game function to trigger sell G.FUNCS.sell_card(mock_element) - -- Wait for sell completion with comprehensive verification + -- Wait for sell completion with verification G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, func = function() - -- Check all 5 completion criteria - local current_area = sell_type == "joker" and G.jokers or G.consumeables - local current_array = current_area.cards - - -- 1. Card count decreased by 1 - local count_decreased = (current_area.config.card_count == initial_count - 1) + -- 1. Card was removed + local card_removed = card.removed == true -- 2. Money increased by sell_cost local money_increased = (G.GAME.dollars == expected_money) - -- 3. Card no longer exists (verify by unique_val) - local card_gone = true - for _, c in ipairs(current_array) do - if c.sort_id == card_id then - card_gone = false - break - end - end - - -- 4. State stability + -- 3. State stability local state_stable = G.STATE_COMPLETE == true - -- 5. Still in valid state - local valid_state = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) + -- 4. Still in valid state + local valid_state = ( + G.STATE == G.STATES.SHOP + or G.STATE == G.STATES.SELECTING_HAND + or G.STATE == G.STATES.SMODS_BOOSTER_OPENED + ) - -- All conditions must be met - if count_decreased and money_increased and card_gone and state_stable and valid_state then - sendDebugMessage("Return sell()", "BB.ENDPOINTS") + if card_removed and money_increased and state_stable and valid_state then + sendDebugMessage("sell() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua index 5885d455..56eac61c 100644 --- a/src/lua/endpoints/set.lua +++ b/src/lua/endpoints/set.lua @@ -12,6 +12,7 @@ ---@field hands integer? New number of hands left number ---@field discards integer? New number of discards left number ---@field shop boolean? Re-stock shop with new items +---@field boss string? Override which Boss Blind appears -- ========================================================================== -- Set Endpoint @@ -60,6 +61,11 @@ return { required = false, description = "Re-stock shop with new items", }, + boss = { + type = "string", + required = false, + description = "Override which Boss Blind appears", + }, }, requires_state = nil, @@ -67,7 +73,35 @@ return { ---@param args Request.Endpoint.Set.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init set()", "BB.ENDPOINTS") + sendDebugMessage("set()", "BB.ENDPOINTS") + + -- Build fields string for logging + local fields = {} + if args.money ~= nil then + table.insert(fields, "money=" .. args.money) + end + if args.chips ~= nil then + table.insert(fields, "chips=" .. args.chips) + end + if args.ante ~= nil then + table.insert(fields, "ante=" .. args.ante) + end + if args.round ~= nil then + table.insert(fields, "round=" .. args.round) + end + if args.hands ~= nil then + table.insert(fields, "hands=" .. args.hands) + end + if args.discards ~= nil then + table.insert(fields, "discards=" .. args.discards) + end + if args.shop ~= nil then + table.insert(fields, "shop") + end + if args.boss ~= nil then + table.insert(fields, "boss=" .. tostring(args.boss)) + end + sendInfoMessage("Setting " .. table.concat(fields, ", "), "BB.ENDPOINTS") -- Validate we're in a run if G.STAGE and G.STAGE ~= G.STAGES.RUN then @@ -87,6 +121,7 @@ return { and args.hands == nil and args.discards == nil and args.shop == nil + and args.boss == nil then send_response({ message = "Must provide at least one field to set", @@ -95,6 +130,51 @@ return { return end + -- Boss + shop mutual exclusion + if args.boss and args.shop then + send_response({ + message = "Cannot set boss and shop at the same time", + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + + -- Boss validation + if args.boss then + if G.STATE ~= G.STATES.BLIND_SELECT then + send_response({ + message = "Can only set boss blind during blind selection (BLIND_SELECT state)", + name = BB_ERROR_NAMES.INVALID_STATE, + }) + return + end + + local boss_state = G.GAME.round_resets.blind_states.Boss + if boss_state ~= "Upcoming" then + send_response({ + message = "Boss blind is not selectable (current state: " .. tostring(boss_state) .. ")", + name = BB_ERROR_NAMES.INVALID_STATE, + }) + return + end + + if not G.P_BLINDS[args.boss] then + send_response({ + message = "Unknown boss blind key: " .. args.boss, + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + + if not G.P_BLINDS[args.boss].boss then + send_response({ + message = "Not a boss blind: " .. args.boss, + name = BB_ERROR_NAMES.BAD_REQUEST, + }) + return + end + end + -- Set money if args.money then if args.money < 0 then @@ -188,6 +268,14 @@ return { G:update_shop() end + -- Boss execution: inject desired boss via perscribed_bosses and reroll + if args.boss then + G.GAME.perscribed_bosses = G.GAME.perscribed_bosses or {} + G.GAME.perscribed_bosses[G.GAME.round_resets.ante] = args.boss + G.from_boss_tag = true + G.FUNCS.reroll_boss() + end + G.E_MANAGER:add_event(Event({ trigger = "condition", blocking = false, @@ -197,14 +285,24 @@ return { local done_packs = G.shop_booster and G.shop_booster.config and G.shop_booster.config.card_count > 0 local done_jokers = G.shop_jokers and G.shop_jokers.config and G.shop_jokers.config.card_count > 0 if done_vouchers or done_packs or done_jokers then - sendDebugMessage("Return set()", "BB.ENDPOINTS") + sendDebugMessage("set() → ok", "BB.ENDPOINTS") + local state_data = BB_GAMESTATE.get_gamestate() + send_response(state_data) + return true + end + return false + elseif args.boss then + -- Wait for boss reroll to complete (controller lock releases after 0.5s) + if G.CONTROLLER.locks.boss_reroll == nil then + G.GAME.round_resets.boss_rerolled = false + sendDebugMessage("set() → ok (boss)", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true end return false else - sendDebugMessage("Return set()", "BB.ENDPOINTS") + sendDebugMessage("set() → ok", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) return true diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua index 0e684bed..cac3fee7 100644 --- a/src/lua/endpoints/skip.lua +++ b/src/lua/endpoints/skip.lua @@ -24,7 +24,7 @@ return { ---@param _ Request.Endpoint.Skip.Params ---@param send_response fun(response: Response.Endpoint) execute = function(_, send_response) - sendDebugMessage("Init skip()", "BB.ENDPOINTS") + sendDebugMessage("skip()", "BB.ENDPOINTS") -- Get the current blind on deck (similar to select endpoint) local current_blind = G.GAME.blind_on_deck @@ -34,9 +34,9 @@ return { assert(blind ~= nil, "skip() blind not found: " .. current_blind) if blind.type == "BOSS" then - sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS") + sendWarnMessage("Cannot skip Boss blind", "BB.ENDPOINTS") send_response({ - message = "Cannot skip Boss blind", + message = "Cannot skip Boss blind. Use `select` to select and play the boss blind.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return @@ -51,6 +51,7 @@ return { assert(skip_button ~= nil, "skip() skip button not found: " .. current_blind) -- Execute blind skip + sendInfoMessage("Skipping " .. current_blind_key .. " blind", "BB.ENDPOINTS") G.FUNCS.skip_blind(skip_button) -- Wait for the skip to complete @@ -67,7 +68,7 @@ return { and blinds[current_blind_key].status == "SKIPPED" ) if done then - sendDebugMessage("Return skip()", "BB.ENDPOINTS") + sendDebugMessage("skip() → BLIND_SELECT", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua index 5bcefa62..2847210b 100644 --- a/src/lua/endpoints/start.lua +++ b/src/lua/endpoints/start.lua @@ -5,43 +5,12 @@ -- ========================================================================== ---@class Request.Endpoint.Start.Params ----@field deck Deck deck enum value (e.g., "RED", "BLUE", "YELLOW") ----@field stake Stake stake enum value (e.g., "WHITE", "RED", "GREEN", "BLACK", "BLUE", "PURPLE", "ORANGE", "GOLD") +---@field deck Deck Deck key from G.P_CENTERS (e.g., "b_red", "b_blue") +---@field stake Stake key from G.P_STAKES (e.g., "stake_white", "stake_red", "stake_black") ---@field seed string? optional seed for the run -- ========================================================================== --- Start Endpoint Utils --- ========================================================================== - -local DECK_ENUM_TO_NAME = { - RED = "Red Deck", - BLUE = "Blue Deck", - YELLOW = "Yellow Deck", - GREEN = "Green Deck", - BLACK = "Black Deck", - MAGIC = "Magic Deck", - NEBULA = "Nebula Deck", - GHOST = "Ghost Deck", - ABANDONED = "Abandoned Deck", - CHECKERED = "Checkered Deck", - ZODIAC = "Zodiac Deck", - PAINTED = "Painted Deck", - ANAGLYPH = "Anaglyph Deck", - PLASMA = "Plasma Deck", - ERRATIC = "Erratic Deck", -} - -local STAKE_ENUM_TO_NUMBER = { - WHITE = 1, - RED = 2, - GREEN = 3, - BLACK = 4, - BLUE = 5, - PURPLE = 6, - ORANGE = 7, - GOLD = 8, -} - +-- Start Endpoint -- ========================================================================== -- Start Endpoint -- ========================================================================== @@ -57,12 +26,12 @@ return { deck = { type = "string", required = true, - description = "Deck enum value (e.g., 'RED', 'BLUE', 'YELLOW')", + description = "Deck key from G.P_CENTERS (e.g., 'b_red', 'b_blue')", }, stake = { type = "string", required = true, - description = "Stake enum value (e.g., 'WHITE', 'RED', 'GREEN', 'BLACK', 'BLUE', 'PURPLE', 'ORANGE', 'GOLD')", + description = "Stake key from G.P_STAKES (e.g., 'stake_white', 'stake_red', 'stake_black')", }, seed = { type = "string", @@ -76,27 +45,26 @@ return { ---@param args Request.Endpoint.Start.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init start()", "BB.ENDPOINTS") + sendDebugMessage("start()", "BB.ENDPOINTS") - -- Validate and map stake enum - local stake_number = STAKE_ENUM_TO_NUMBER[args.stake] - if not stake_number then - sendDebugMessage("start() called with invalid stake enum: " .. tostring(args.stake), "BB.ENDPOINTS") + -- Validate and map stake key + local stake_data = G.P_STAKES[args.stake] + if not stake_data then + sendWarnMessage("Invalid stake key: " .. tostring(args.stake), "BB.ENDPOINTS") send_response({ - message = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: " - .. tostring(args.stake), + message = "Expected a stake_* key from G.P_STAKES (e.g. stake_white, stake_red). Got: " .. tostring(args.stake), name = BB_ERROR_NAMES.BAD_REQUEST, }) return end + local stake_number = stake_data.order or stake_data.stake_level - -- Validate and map deck enum - local deck_name = DECK_ENUM_TO_NAME[args.deck] - if not deck_name then - sendDebugMessage("start() called with invalid deck enum: " .. tostring(args.deck), "BB.ENDPOINTS") + -- Validate deck key against G.P_CENTERS + local deck_center = G.P_CENTERS and G.P_CENTERS[args.deck] + if not deck_center or deck_center.set ~= "Back" then + sendWarnMessage("Invalid deck key: " .. tostring(args.deck), "BB.ENDPOINTS") send_response({ - message = "Invalid deck enum. Must be one of: RED, BLUE, YELLOW, GREEN, BLACK, MAGIC, NEBULA, GHOST, ABANDONED, CHECKERED, ZODIAC, PAINTED, ANAGLYPH, PLASMA, ERRATIC. Got: " - .. tostring(args.deck), + message = "Expected a b_* deck key from G.P_CENTERS (e.g. b_red, b_blue). Got: " .. tostring(args.deck), name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -106,12 +74,11 @@ return { G.FUNCS.setup_run({ config = {} }) G.FUNCS.exit_overlay_menu() - -- Find and set the deck using the mapped deck name + -- Find and set the deck using the deck key local deck_found = false if G.P_CENTER_POOLS and G.P_CENTER_POOLS.Back then for _, deck_data in pairs(G.P_CENTER_POOLS.Back) do - if deck_data.name == deck_name then - sendDebugMessage("Setting deck to: " .. deck_data.name .. " (from enum: " .. args.deck .. ")", "BB.ENDPOINTS") + if deck_data.key == args.deck then G.GAME.selected_back:change_to(deck_data) G.GAME.viewed_back:change_to(deck_data) deck_found = true @@ -121,9 +88,9 @@ return { end if not deck_found then - sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS") + sendWarnMessage("Deck not found in G.P_CENTER_POOLS.Back: " .. args.deck, "BB.ENDPOINTS") send_response({ - message = "Deck not found in game data: " .. deck_name, + message = "Deck not found in game data: " .. args.deck, name = BB_ERROR_NAMES.INTERNAL_ERROR, }) return @@ -135,8 +102,10 @@ return { run_params.seed = args.seed end - sendDebugMessage( - "Starting run with stake=" + sendInfoMessage( + "Starting run: " + .. args.deck + .. ", stake=" .. tostring(stake_number) .. " (" .. args.stake @@ -158,7 +127,7 @@ return { and G.blind_select_opts["small"]:get_UIE_by_ID("tag_Small") ~= nil ) if done then - sendDebugMessage("Return start()", "BB.ENDPOINTS") + sendDebugMessage("start() → BLIND_SELECT", "BB.ENDPOINTS") local state_data = BB_GAMESTATE.get_gamestate() send_response(state_data) end diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua index 7801ed37..d8ba1352 100644 --- a/src/lua/endpoints/use.lua +++ b/src/lua/endpoints/use.lua @@ -1,7 +1,7 @@ -- src/lua/endpoints/use.lua ----@type BB_LOGGER -local BB_LOGGER = assert(SMODS.load_file("src/lua/utils/logger.lua"))() +---@type BB_FORMAT +local BB_FORMAT = assert(SMODS.load_file("src/lua/utils/format.lua"))() -- ========================================================================== -- Use Endpoint Params @@ -41,7 +41,7 @@ return { ---@param args Request.Endpoint.Use.Params ---@param send_response fun(response: Response.Endpoint) execute = function(args, send_response) - sendDebugMessage("Init use()", "BB.ENDPOINTS") + sendDebugMessage("use()", "BB.ENDPOINTS") -- Step 1: Consumable Index Validation if args.consumable < 0 or args.consumable >= #G.consumeables.cards then @@ -62,7 +62,7 @@ return { send_response({ message = "Consumable '" .. consumable_card.ability.name - .. "' requires card selection and can only be used in SELECTING_HAND state", + .. "' requires card selection and can only be used in SELECTING_HAND state.", name = BB_ERROR_NAMES.INVALID_STATE, }) return @@ -72,7 +72,9 @@ return { if requires_cards then if not args.cards or #args.cards == 0 then send_response({ - message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection", + message = "Consumable '" + .. consumable_card.ability.name + .. "' requires card selection. Provide target cards via the `cards` parameter.", name = BB_ERROR_NAMES.BAD_REQUEST, }) return @@ -100,7 +102,7 @@ return { if min_cards == max_cards and card_count ~= min_cards then send_response({ message = string.format( - "Consumable '%s' requires exactly %d card%s (provided: %d)", + "Consumable '%s' requires exactly %d card%s (provided: %d). Provide the correct number of cards via the `cards` parameter.", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -115,7 +117,7 @@ return { if card_count < min_cards then send_response({ message = string.format( - "Consumable '%s' requires at least %d card%s (provided: %d)", + "Consumable '%s' requires at least %d card%s (provided: %d). Provide more cards via the `cards` parameter.", consumable_card.ability.name, min_cards, min_cards == 1 and "" or "s", @@ -129,7 +131,7 @@ return { if card_count > max_cards then send_response({ message = string.format( - "Consumable '%s' requires at most %d card%s (provided: %d)", + "Consumable '%s' requires at most %d card%s (provided: %d). Provide fewer cards via the `cards` parameter.", consumable_card.ability.name, max_cards, max_cards == 1 and "" or "s", @@ -158,10 +160,10 @@ return { -- Log what we're using with target cards local cons_name = consumable_card.ability.name if args.cards and #args.cards > 0 then - local targets = BB_LOGGER.format_playing_cards(G.hand.cards, args.cards) - sendDebugMessage(string.format("Using '%s' on: %s", cons_name, targets), "BB.ENDPOINTS") + local targets = BB_FORMAT.format_playing_cards(G.hand.cards, args.cards) + sendInfoMessage(string.format("Using '%s' on: %s", cons_name, targets), "BB.ENDPOINTS") else - sendDebugMessage(string.format("Using '%s' (no targets)", cons_name), "BB.ENDPOINTS") + sendInfoMessage(string.format("Using '%s'", cons_name), "BB.ENDPOINTS") end -- Step 7: Game-Level Validation (e.g. try to use Familiar Spectral when G.hand is not available) @@ -176,7 +178,9 @@ return { -- Step 8: Space Check (not tested) if consumable_card:check_use() then send_response({ - message = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space", + message = "Cannot use consumable '" + .. consumable_card.ability.name + .. "': insufficient space. Use `sell` or `use` to free up space.", name = BB_ERROR_NAMES.NOT_ALLOWED, }) return @@ -207,7 +211,7 @@ return { local no_stop_use = not (G.GAME.STOP_USE and G.GAME.STOP_USE > 0) if state_restored and controller_unlocked and no_stop_use then - sendDebugMessage("Return use()", "BB.ENDPOINTS") + sendDebugMessage("use() → ok", "BB.ENDPOINTS") send_response(BB_GAMESTATE.get_gamestate()) return true end diff --git a/src/lua/profiles/default/profile.lua b/src/lua/profiles/default/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/default/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/default/settings.lua b/src/lua/profiles/default/settings.lua new file mode 100644 index 00000000..a6be1591 --- /dev/null +++ b/src/lua/profiles/default/settings.lua @@ -0,0 +1,24 @@ +return { + ["GAMESPEED"] = 1, + ["SOUND"] = { + ["volume"] = 50, + ["music_volume"] = 100, + ["game_sounds_volume"] = 100, + }, + ["GRAPHICS"] = { + ["shadows"] = "On", + ["texture_scaling"] = 2, + ["crt"] = 70, + ["bloom"] = 1, + }, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 1, + }, + ["screenshake"] = 50, + ["reduced_motion"] = false, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["crashreports"] = false, +} diff --git a/src/lua/profiles/fast/profile.lua b/src/lua/profiles/fast/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/fast/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/fast/settings.lua b/src/lua/profiles/fast/settings.lua new file mode 100644 index 00000000..5f835554 --- /dev/null +++ b/src/lua/profiles/fast/settings.lua @@ -0,0 +1,24 @@ +return { + ["GAMESPEED"] = 4, + ["SOUND"] = { + ["volume"] = 50, + ["music_volume"] = 100, + ["game_sounds_volume"] = 100, + }, + ["GRAPHICS"] = { + ["shadows"] = "On", + ["texture_scaling"] = 2, + ["crt"] = 70, + ["bloom"] = 1, + }, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 1, + }, + ["screenshake"] = 50, + ["reduced_motion"] = false, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["crashreports"] = false, +} diff --git a/src/lua/profiles/light/profile.lua b/src/lua/profiles/light/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/light/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/light/settings.lua b/src/lua/profiles/light/settings.lua new file mode 100644 index 00000000..09bf17b0 --- /dev/null +++ b/src/lua/profiles/light/settings.lua @@ -0,0 +1,24 @@ +return { + ["GAMESPEED"] = 1, + ["SOUND"] = { + ["volume"] = 0, + ["music_volume"] = 0, + ["game_sounds_volume"] = 0, + }, + ["GRAPHICS"] = { + ["shadows"] = "Off", + ["texture_scaling"] = 1, + ["crt"] = 0, + ["bloom"] = 0, + }, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 1, + }, + ["screenshake"] = 0, + ["reduced_motion"] = true, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["crashreports"] = false, +} diff --git a/src/lua/profiles/turbo/profile.lua b/src/lua/profiles/turbo/profile.lua new file mode 100644 index 00000000..a5647075 --- /dev/null +++ b/src/lua/profiles/turbo/profile.lua @@ -0,0 +1 @@ +return {} diff --git a/src/lua/profiles/turbo/settings.lua b/src/lua/profiles/turbo/settings.lua new file mode 100644 index 00000000..3803c0fe --- /dev/null +++ b/src/lua/profiles/turbo/settings.lua @@ -0,0 +1,24 @@ +return { + ["GAMESPEED"] = 128, + ["SOUND"] = { + ["volume"] = 0, + ["music_volume"] = 0, + ["game_sounds_volume"] = 0, + }, + ["GRAPHICS"] = { + ["shadows"] = "Off", + ["texture_scaling"] = 1, + ["crt"] = 0, + ["bloom"] = 0, + }, + ["WINDOW"] = { + ["screenmode"] = "Windowed", + ["vsync"] = 1, + }, + ["screenshake"] = 0, + ["reduced_motion"] = true, + ["skip_splash"] = "Yes", + ["tutorial_complete"] = true, + ["current_setup"] = "New Run", + ["crashreports"] = false, +} diff --git a/src/lua/settings.lua b/src/lua/settings.lua index e92043ca..d91f71b6 100644 --- a/src/lua/settings.lua +++ b/src/lua/settings.lua @@ -1,288 +1,188 @@ --[[ -BalatroBot configure settings in Balatro using the following environment variables: - - - BALATROBOT_HOST: the hostname when the TCP server is running. - Type string (default: 127.0.0.1) - - - BALATROBOT_PORT: the port when the TCP server is running. - Type string (default: 12346) - - - BALATROBOT_HEADLESS: whether to run in headless mode. - 1 for actiavate the headeless mode, 0 for running headed (default: 0) - - - BALATROBOT_FAST: whether to run in fast mode. - 1 for actiavate the fast mode, 0 for running slow (default: 0) - - - BALATROBOT_RENDER_ON_API: whether to render frames only on API calls. - 1 for actiavate the render on API mode, 0 for normal rendering (default: 0) - - - BALATROBOT_AUDIO: whether to play audio. - 1 for actiavate the audio mode, 0 for no audio (default: 0) - - - BALATROBOT_DEBUG: whether enable debug mode. It requires DebugPlus mod to be running. - 1 for actiavate the debug mode, 0 for no debug (default: 0) - - - BALATROBOT_NO_SHADERS: whether to disable all shaders for better performance. - 1 for disable shaders, 0 for enable shaders (default: 0) - - - BALATROBOT_FPS_CAP: the maximum FPS cap for the game. - Type number (default: 60) - - - BALATROBOT_GAMESPEED: the game speed multiplier. - Type number (default: 4) - - - BALATROBOT_ANIMATION_FPS: the animation FPS. - Type number (default: 10) - - - BALATROBOT_NO_REDUCED_MOTION: whether to disable reduced motion. - 1 for disable reduced motion, 0 for enable reduced motion (default: 0) - - - BALATROBOT_PIXEL_ART_SMOOTHING: whether to enable pixel art smoothing. - 1 for enable pixel art smoothing, 0 for disable (default: 0) +BalatroBot settings — profile-based configuration. + +Environment variables read by the Lua mod: + BALATROBOT_HOST - Server hostname (default: 127.0.0.1) + BALATROBOT_PORT - Server port (set internally by launcher, default: 12346) + BALATROBOT_RENDER - Render mode: headfull|headless|ondemand (default: headfull) + BALATROBOT_DEBUG - Enable debug endpoints (1/0, default: 0) + BALATROBOT_SETTINGS - Settings profile name (bare name, e.g. "fast", "turbo", "light") ]] ---@diagnostic disable: duplicate-set-field ----@type Settings -BB_SETTINGS = { - host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", - port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, - headless = os.getenv("BALATROBOT_HEADLESS") == "1" or false, - fast = os.getenv("BALATROBOT_FAST") == "1" or false, - render_on_api = os.getenv("BALATROBOT_RENDER_ON_API") == "1" or false, - audio = os.getenv("BALATROBOT_AUDIO") == "1" or false, - debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, - no_shaders = os.getenv("BALATROBOT_NO_SHADERS") == "1" or false, - fps_cap = tonumber(os.getenv("BALATROBOT_FPS_CAP")) or 60, - gamespeed = tonumber(os.getenv("BALATROBOT_GAMESPEED")) or 4, - animation_fps = tonumber(os.getenv("BALATROBOT_ANIMATION_FPS")) or 10, - no_reduced_motion = os.getenv("BALATROBOT_NO_REDUCED_MOTION") == "1" or false, - pixel_art_smoothing = os.getenv("BALATROBOT_PIXEL_ART_SMOOTHING") == "1" or false, -} - ----@type boolean? -BB_RENDER = nil - ---- Patches love.update to use a fixed delta time based on headless mode ---- Headless mode uses 4.99/60 for faster simulation, normal mode uses 1/60 ----@return nil -local function configure_love_update() - local love_update = love.update - local dt = BB_SETTINGS.headless and (4.99 / 60.0) or (1.0 / 60.0) - love.update = function(_) - love_update(dt) +--- Deep merge source into target table (recursive) +---@param target table +---@param source table +local function deep_merge(target, source) + for k, v in pairs(source) do + if type(v) == "table" and type(target[k]) == "table" then + deep_merge(target[k], v) + else + target[k] = v + end end - sendDebugMessage("Patched love.update with dt=" .. dt, "BB.SETTINGS") end ---- Configures base game settings for optimal bot performance ---- Disables audio, sets high game speed, reduces visual effects, and disables tutorials ----@return nil -local function configure_settings() - -- disable audio - G.SETTINGS.SOUND.volume = 0 - G.SETTINGS.SOUND.music_volume = 0 - G.SETTINGS.SOUND.game_sounds_volume = 0 - G.F_SOUND_THREAD = false - G.F_MUTE = true - - -- performance - G.FPS_CAP = BB_SETTINGS.fps_cap - G.SETTINGS.GAMESPEED = BB_SETTINGS.gamespeed - G.ANIMATION_FPS = BB_SETTINGS.animation_fps - - -- features - G.F_SKIP_TUTORIAL = true - G.VIBRATION = 0 - G.F_VERBOSE = true - G.F_RUMBLE = nil - - -- graphics - G.SETTINGS.GRAPHICS = G.SETTINGS.GRAPHICS or {} - G.SETTINGS.GRAPHICS.shadows = "Off" -- Always disable shadows - G.SETTINGS.GRAPHICS.bloom = 0 -- Always disable CRT bloom - G.SETTINGS.GRAPHICS.crt = 0 -- Always disable CRT - G.SETTINGS.GRAPHICS.texture_scaling = BB_SETTINGS.pixel_art_smoothing and 2 or 1 - - -- visuals - G.SETTINGS.skip_splash = "Yes" -- Skip intro animation - G.SETTINGS.reduced_motion = not BB_SETTINGS.no_reduced_motion - G.SETTINGS.screenshake = false - G.SETTINGS.rumble = nil +--- Apply settings profile by name +---@param name string Profile name (e.g. "default", "fast", "turbo", "light") +local function apply_profile(name) + assert(name:match("^[a-zA-Z0-9][a-zA-Z0-9_-]*$"), "Invalid profile name: " .. name) + local NFS = require("nativefs") + + local profile_dir = SMODS.current_mod.path .. "src/lua/profiles/" .. name .. "/" + + -- Deep merge settings.lua into G.SETTINGS (required) + local settings_src = NFS.read(profile_dir .. "settings.lua") + if not settings_src then + -- List available profiles for error message + local items = NFS.getDirectoryItems(SMODS.current_mod.path .. "src/lua/profiles/") + local available = {} + for _, item in ipairs(items) do + table.insert(available, item) + end + sendErrorMessage( + "Settings profile not found: '" .. name .. "'. Available: " .. table.concat(available, ", "), + "BB.SETTINGS" + ) + error("Settings profile not found: '" .. name .. "'") + end + local profile_settings = assert(load(settings_src))() + assert(type(profile_settings) == "table", "settings.lua must return a table") + deep_merge(G.SETTINGS, profile_settings) + + -- Deep merge profile.lua into G.PROFILES[n] (optional) + local profile_src = NFS.read(profile_dir .. "profile.lua") + if profile_src then + local profile_data = assert(load(profile_src))() + assert(type(profile_data) == "table", "profile.lua must return a table") + local n = G.SETTINGS.profile or 1 + G.PROFILES[n] = G.PROFILES[n] or {} + deep_merge(G.PROFILES[n], profile_data) + end - -- Window - love.window.setVSync(0) - G.SETTINGS.WINDOW = G.SETTINGS.WINDOW or {} - G.SETTINGS.WINDOW.vsync = 0 + sendInfoMessage("Applied profile: " .. name, "BB.SETTINGS") end ---- Configures headless mode by minimizing and hiding the window ---- Disables all rendering operations, graphics, and window updates ----@return nil +--- Headless mode: disable all rendering and window operations local function configure_headless() if love.window and love.window.isOpen() then if love.window.minimize then love.window.minimize() - sendDebugMessage("Minimized window", "BB.SETTINGS") end - love.window.setMode(1, 1) love.window.setPosition(-1000, -1000) - sendDebugMessage("Set window to 1x1 and moved to (-1000, -1000)", "BB.SETTINGS") end - -- Disable all rendering operations love.graphics.isActive = function() return false end + love.draw = function() end + love.graphics.present = function() end - -- Disable drawing operations - love.draw = function() - -- Do nothing in headless mode - end - - -- Disable graphics present/swap buffers - love.graphics.present = function() - -- Do nothing in headless mode - end - - -- Disable window creation/updates for future calls if love.window then love.window.setMode = function() return false end - love.window.isOpen = function() return false end - - love.window.setPosition = function() - -- Do nothing - end - - love.window.minimize = function() - -- Do nothing - end - - love.window.maximize = function() - -- Do nothing - end - - love.window.restore = function() - -- Do nothing - end - - love.window.requestAttention = function() - -- Do nothing - end - + love.window.setPosition = function() end + love.window.minimize = function() end + love.window.maximize = function() end + love.window.restore = function() end + love.window.requestAttention = function() end love.window.setFullscreen = function() return false end - love.graphics.isCreated = function() return false end end - sendDebugMessage("Headless mode enabled", "BB.SETTINGS") + sendInfoMessage("Render mode: headless", "BB.SETTINGS") end ---- Configures render-on-API mode where frames are only rendered when BB_RENDER is true ---- Patches love.draw and love.graphics.present to conditionally render based on BB_RENDER flag ----@return nil -local function configure_render_on_api() +--- On-demand rendering: only render when BB_RENDER is set +local function configure_ondemand() BB_RENDER = false - -- Original render function local love_draw = love.draw local love_graphics_present = love.graphics.present - - local did_render_this_frame = false + local did_render = false love.draw = function() if BB_RENDER then love_draw() - did_render_this_frame = true + did_render = true BB_RENDER = false else - did_render_this_frame = false + did_render = false end end love.graphics.present = function() - if did_render_this_frame then + if did_render then love_graphics_present() - did_render_this_frame = false + did_render = false end end - sendDebugMessage("Render on API mode enabled", "BB.SETTINGS") -end - ---- Configures fast mode with unlimited FPS, 10x game speed, and 60 FPS animations ----@return nil -local function configure_fast() - -- performance - G.FPS_CAP = nil -- Unlimited FPS - G.SETTINGS.GAMESPEED = 10 -- 10x game speed - G.ANIMATION_FPS = 60 -- 6x faster animations - G.F_VERBOSE = false -end - ---- Disables all shaders by overriding love.graphics.setShader to always pass nil ---- This improves performance by bypassing shader compilation and rendering ---- Disabling shaders cause visual glitches. Use at your own risk. ----@return nil -local function configure_no_shaders() - local love_graphics_setShader = love.graphics.setShader - love.graphics.setShader = function() - return love_graphics_setShader() - end - sendDebugMessage("Disabled all shaders", "BB.SETTINGS") -end - ---- Enables audio by setting volume levels and enabling sound thread ----@return nil -local function configure_audio() - G.SETTINGS.SOUND = G.SETTINGS.SOUND or {} - G.SETTINGS.SOUND.volume = 50 - G.SETTINGS.SOUND.music_volume = 100 - G.SETTINGS.SOUND.game_sounds_volume = 100 - G.F_MUTE = false - G.F_SOUND_THREAD = true + sendInfoMessage("Render mode: ondemand", "BB.SETTINGS") end ---- Initializes and applies all BalatroBot settings based on environment variables ---- Orchestrates configuration of love.update, game settings, and optional features ---- (headless, render-on-api, fast mode, audio) ----@return nil -BB_SETTINGS.setup = function() - configure_love_update() - configure_settings() - - if BB_SETTINGS.headless and BB_SETTINGS.render_on_api then - sendWarnMessage("Headless mode and render on API mode are mutually exclusive. Disabling headless", "BB.SETTINGS") - BB_SETTINGS.headless = false +--- Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. +---@return boolean +local function setup() + -- Gate: only activate when in-game profile is named "BalatroBot" + local profile_num = G.SETTINGS.profile or 1 + local profile = G.PROFILES[profile_num] + if not profile or profile.name ~= "BalatroBot" then + sendWarnMessage( + "BalatroBot profile not selected. Create a profile named 'BalatroBot' and select it.", + "BB.SETTINGS" + ) + return false end - if BB_SETTINGS.headless then + -- Hardcoded overrides for bot operation + G.F_SKIP_TUTORIAL = true + G.F_ENGLISH_ONLY = true + G.F_NO_ACHIEVEMENTS = true + G.F_VERBOSE = BB_SETTINGS.debug + G.PROFILES[profile_num].all_unlocked = true + + -- Apply settings profile (default if none specified) + BB_SETTINGS.settings = BB_SETTINGS.settings or "default" + apply_profile(BB_SETTINGS.settings) + + -- Render mode + if BB_SETTINGS.render == "headfull" then + -- default, no special configuration needed + elseif BB_SETTINGS.render == "headless" then configure_headless() + elseif BB_SETTINGS.render == "ondemand" then + configure_ondemand() + else + sendErrorMessage( + "Invalid render mode '" .. BB_SETTINGS.render .. "'. Must be headfull, headless, or ondemand. Aborting.", + "BB.SETTINGS" + ) + return false end - if BB_SETTINGS.render_on_api then - configure_render_on_api() - end - - if BB_SETTINGS.fast then - configure_fast() - end + return true +end - if BB_SETTINGS.no_shaders then - configure_no_shaders() - end +---@type Settings +BB_SETTINGS = { + host = os.getenv("BALATROBOT_HOST") or "127.0.0.1", + port = tonumber(os.getenv("BALATROBOT_PORT")) or 12346, + render = os.getenv("BALATROBOT_RENDER") or "headfull", + debug = os.getenv("BALATROBOT_DEBUG") == "1" or false, + settings = os.getenv("BALATROBOT_SETTINGS"), + setup = setup, +} - if BB_SETTINGS.audio then - configure_audio() - end -end +---@type boolean? +BB_RENDER = nil diff --git a/src/lua/utils/debugger.lua b/src/lua/utils/debugger.lua index 186bcb8b..f5bd6f01 100644 --- a/src/lua/utils/debugger.lua +++ b/src/lua/utils/debugger.lua @@ -9,7 +9,7 @@ table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/echo.lua") table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/state.lua") table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/error.lua") table.insert(BB_ENDPOINTS, "src/lua/endpoints/tests/validation.lua") -sendDebugMessage("Loading test endpoints", "BB.BALATROBOT") +sendInfoMessage("Loading test endpoints", "BB.BALATROBOT") -- Helper function to format response as pretty-printed table local function format_response(response, depth, indent) @@ -121,16 +121,16 @@ BB_DEBUG = { BB_DEBUG.setup = function() local success, dpAPI = pcall(require, "debugplus.api") if not success or not dpAPI then - sendDebugMessage("DebugPlus API not found", "BB.DEBUGGER") + sendWarnMessage("DebugPlus API not found", "BB.DEBUGGER") return end if not dpAPI.isVersionCompatible(1) then - sendDebugMessage("DebugPlus API version is not compatible", "BB.DEBUGGER") + sendWarnMessage("DebugPlus API version not compatible", "BB.DEBUGGER") return end local dp = dpAPI.registerID("BalatroBot") if not dp then - sendDebugMessage("Failed to register with DebugPlus", "BB.DEBUGGER") + sendWarnMessage("Failed to register with DebugPlus", "BB.DEBUGGER") return end diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua index 3d563de0..9b9b72fa 100644 --- a/src/lua/utils/enums.lua +++ b/src/lua/utils/enums.lua @@ -1,31 +1,31 @@ ---@meta enums ---@alias Deck ----| "RED" # +1 discard every round ----| "BLUE" # +1 hand every round ----| "YELLOW" # Start with extra $10 ----| "GREEN" # At end of each Round, $2 per remaining Hand $1 per remaining Discard Earn no Interest ----| "BLACK" # +1 Joker slot -1 hand every round ----| "MAGIC" # Start run with the Cristal Ball voucher and 2 copies of The Fool ----| "NEBULA" # Start run with the Telescope voucher and -1 consumable slot ----| "GHOST" # Spectral cards may appear in the shop. Start with a Hex card ----| "ABANDONED" # Start run with no Face Cards in your deck ----| "CHECKERED" # Start run with 26 Spaces and 26 Hearts in deck ----| "ZODIAC" # Start run with Tarot Merchant, Planet Merchant, and Overstock ----| "PAINTED" # +2 hand size, -1 Joker slot ----| "ANAGLYPH" # After defeating each Boss Blind, gain a Double Tag ----| "PLASMA" # Balanced Chips and Mult when calculating score for played hand X2 base Blind size ----| "ERRATIC" # All Ranks and Suits in deck are randomized +---| "b_red" # Red Deck: +1 discard every round +---| "b_blue" # Blue Deck: +1 hand every round +---| "b_yellow" # Yellow Deck: Start with extra $10 +---| "b_green" # Green Deck: $2 per remaining Hand, $1 per remaining Discard, no interest +---| "b_black" # Black Deck: +1 Joker slot, -1 hand every round +---| "b_magic" # Magic Deck: Start with Crystal Ball and 2 copies of The Fool +---| "b_nebula" # Nebula Deck: Start with Telescope, -1 consumable slot +---| "b_ghost" # Ghost Deck: Spectral cards may appear in shop, start with Hex +---| "b_abandoned" # Abandoned Deck: No Face Cards in starting deck +---| "b_checkered" # Checkered Deck: 26 Spades and 26 Hearts in deck +---| "b_zodiac" # Zodiac Deck: Start with Tarot Merchant, Planet Merchant, and Overstock +---| "b_painted" # Painted Deck: +2 hand size, -1 Joker slot +---| "b_anaglyph" # Anaglyph Deck: Double Tag after each Boss Blind +---| "b_plasma" # Plasma Deck: Balanced Chips/Mult, 2X base Blind size +---| "b_erratic" # Erratic Deck: Random Ranks and Suits ---@alias Stake ----| "WHITE" # 1. Base Difficulty ----| "RED" # 2. Small Blind gives no reward money. Applies all previous Stakes ----| "GREEN" # 3. Required scores scales faster for each Ante. Applies all previous Stakes ----| "BLACK" # 4. Shop can have Eternal Jokers. Applies all previous Stakes ----| "BLUE" # 5. -1 Discard. Applies all previous Stakes ----| "PURPLE" # 6. Required score scales faster for each Ante. Applies all previous Stakes ----| "ORANGE" # 7. Shop can have Perishable Jokers. Applies all previous Stakes ----| "GOLD" # 8. Shop can have Rental Jokers. Applies all previous Stakes +---| "stake_white" # 1. Base Difficulty +---| "stake_red" # 2. Small Blind gives no reward money. Applies all previous Stakes +---| "stake_green" # 3. Required scores scales faster for each Ante. Applies all previous Stakes +---| "stake_black" # 4. Shop can have Eternal Jokers. Applies all previous Stakes +---| "stake_blue" # 5. -1 Discard. Applies all previous Stakes +---| "stake_purple" # 6. Required score scales faster for each Ante. Applies all previous Stakes +---| "stake_orange" # 7. Shop can have Perishable Jokers. Applies all previous Stakes +---| "stake_gold" # 8. Shop can have Rental Jokers. Applies all previous Stakes ---@alias State ---| "SELECTING_HAND" # 1 When you can select cards to play or discard @@ -379,26 +379,26 @@ ---| Card.Key.Pack ---@alias Card.Modifier.Seal ----| "RED" # Retrigger this card 1 time ----| "BLUE" # Creates the Planet card for final played poker hand of round if held in hand (Must have room) ----| "GOLD" # Earn $3 when this card is played and scores ----| "PURPLE" # Creates a Tarot card when discarded (Must have room) +---| "Red" # Retrigger this card 1 time +---| "Blue" # Creates the Planet card for final played poker hand of round if held in hand (Must have room) +---| "Gold" # Earn $3 when this card is played and scores +---| "Purple" # Creates a Tarot card when discarded (Must have room) ---@alias Card.Modifier.Edition ----| "HOLO" # +10 Mult when scored (Playing cards). +10 Mult directly before the Joker is reached during scoring (Jokers) ----| "FOIL" # +50 Chips when scored (Playing cards). +50 Chips directly before the Joker is reached during scoring (Jokers) ----| "POLYCHROME" # X1.5 Mult when scored (Playing cards). X1.5 Mult directly after the Joker is reached during scoring (Jokers) ----| "NEGATIVE" # N/A (Playing cards). +1 Joker slot (Jokers). +1 Consumable slot (Consumables) +---| "e_holo" # +10 Mult when scored (Playing cards). +10 Mult directly before the Joker is reached during scoring (Jokers) +---| "e_foil" # +50 Chips when scored (Playing cards). +50 Chips directly before the Joker is reached during scoring (Jokers) +---| "e_polychrome" # X1.5 Mult when scored (Playing cards). X1.5 Mult directly after the Joker is reached during scoring (Jokers) +---| "e_negative" # N/A (Playing cards). +1 Joker slot (Jokers). +1 Consumable slot (Consumables) ---@alias Card.Modifier.Enhancement ----| "BONUS" # Enhanced card gives an additional +30 Chips when scored ----| "MULT" # Enhanced card gives +4 Mult when scored ----| "WILD" # Enhanced card is considered to be every suit simultaneously ----| "GLASS" # Enhanced card gives X2 Mult when scored ----| "STEEL" # Enhanced card gives X1.5 Mult while held in hand ----| "STONE" # Enhanced card's value is set to +50 Chips ----| "GOLD" # Enhanced card gives $3 if held in hand at end of round ----| "LUCKY" # Enhanced card has a 1 in 5 chance to give +20 Mult. Enhanced card has a 1 in 15 chance to give $20 +---| "m_bonus" # Enhanced card gives an additional +30 Chips when scored +---| "m_mult" # Enhanced card gives +4 Mult when scored +---| "m_wild" # Enhanced card is considered to be every suit simultaneously +---| "m_glass" # Enhanced card gives X2 Mult when scored +---| "m_steel" # Enhanced card gives X1.5 Mult while held in hand +---| "m_stone" # Enhanced card's value is set to +50 Chips +---| "m_gold" # Enhanced card gives $3 if held in hand at end of round +---| "m_lucky" # Enhanced card has a 1 in 5 chance to give +20 Mult. Enhanced card has a 1 in 15 chance to give $20 ---@alias Blind.Type ---| "SMALL" # No special effects - can be skipped to receive a Tag @@ -411,3 +411,61 @@ ---| "UPCOMING" # Future blind ---| "DEFEATED" # Previously defeated blind ---| "SKIPPED" # Previously skipped blind + +---@alias Blind.Key +---| "bl_small" # Small Blind: No special effects - can be skipped to receive a Tag +---| "bl_big" # Big Blind: No special effects - can be skipped to receive a Tag +---| "bl_hook" # The Hook: Discards 2 random cards held in hand after every played hand +---| "bl_ox" # The Ox: Playing the most played hand this run sets money to $0 +---| "bl_mouth" # The Mouth: Only one hand type can be played this round +---| "bl_fish" # The Fish: Cards drawn face down after each hand played +---| "bl_club" # The Club: All Club cards are debuffed +---| "bl_manacle" # The Manacle: −1 Hand Size +---| "bl_tooth" # The Tooth: Lose $1 per card played +---| "bl_wall" # The Wall: Extra large blind (4× base chips required) +---| "bl_house" # The House: First hand is drawn face down +---| "bl_mark" # The Mark: All face cards are drawn face down +---| "bl_wheel" # The Wheel: 1 in 7 cards get drawn face down during the round +---| "bl_arm" # The Arm: Decrease level of played poker hand by 1 +---| "bl_psychic" # The Psychic: Must play 5 cards (not all cards need to score) +---| "bl_goad" # The Goad: All Spade cards are debuffed +---| "bl_water" # The Water: Start with 0 discards +---| "bl_eye" # The Eye: No repeat hand types this round +---| "bl_plant" # The Plant: All face cards are debuffed +---| "bl_needle" # The Needle: Play only 1 hand +---| "bl_head" # The Head: All Heart cards are debuffed +---| "bl_window" # The Window: All Diamond cards are debuffed +---| "bl_serpent" # The Serpent: After Play or Discard, always draw 3 cards (ignores hand size) +---| "bl_pillar" # The Pillar: Cards played previously this Ante are debuffed +---| "bl_flint" # The Flint: Base Chips and Mult for played poker hands are halved +---| "bl_final_acorn" # Amber Acorn: Flips and shuffles all Joker cards (Showdown) +---| "bl_final_bell" # Cerulean Bell: Forces 1 card to always be selected (Showdown) +---| "bl_final_heart" # Crimson Heart: One random Joker disabled every hand (Showdown) +---| "bl_final_leaf" # Verdant Leaf: All cards debuffed until 1 Joker is sold (Showdown) +---| "bl_final_vessel" # Violet Vessel: Very large blind, 6× base chips required (Showdown) + +---@alias Tag.Key +---| "tag_uncommon" # Uncommon Tag: Shop has a free Uncommon Joker +---| "tag_rare" # Rare Tag: Shop has a free Rare Joker +---| "tag_negative" # Negative Tag: Next base edition shop Joker is free and becomes Negative +---| "tag_foil" # Foil Tag: Next base edition shop Joker is free and becomes Foil +---| "tag_holo" # Holographic Tag: Next base edition shop Joker is free and becomes Holographic +---| "tag_polychrome" # Polychrome Tag: Next base edition shop Joker is free and becomes Polychrome +---| "tag_investment" # Investment Tag: Gain $25 after defeating the next Boss Blind +---| "tag_voucher" # Voucher Tag: Adds one Voucher to the next shop +---| "tag_boss" # Boss Tag: Rerolls the Boss Blind +---| "tag_standard" # Standard Tag: Gives a free Mega Standard Pack +---| "tag_charm" # Charm Tag: Gives a free Mega Arcana Pack +---| "tag_meteor" # Meteor Tag: Gives a free Mega Celestial Pack +---| "tag_buffoon" # Buffoon Tag: Gives a free Mega Buffoon Pack +---| "tag_handy" # Handy Tag: Gives $1 per played hand this run +---| "tag_garbage" # Garbage Tag: Gives $1 per unused discard this run +---| "tag_ethereal" # Ethereal Tag: Gives a free Spectral Pack +---| "tag_coupon" # Coupon Tag: Initial cards and booster packs in next shop are free +---| "tag_double" # Double Tag: Gives a copy of the next selected Tag (Double Tag excluded) +---| "tag_juggle" # Juggle Tag: +3 hand size next round +---| "tag_d_six" # D6 Tag: Rerolls in next shop start at $0 +---| "tag_top_up" # Top-up Tag: Create up to 2 Common Jokers (Must have room) +---| "tag_skip" # Skip Tag (aka Speed Tag): Gives $5 per skipped Blind this run +---| "tag_orbital" # Orbital Tag: Upgrade [poker hand] by 3 levels +---| "tag_economy" # Economy Tag: Doubles your money (Max of $40) diff --git a/src/lua/utils/logger.lua b/src/lua/utils/format.lua similarity index 87% rename from src/lua/utils/logger.lua rename to src/lua/utils/format.lua index dd08e58a..686cc77a 100644 --- a/src/lua/utils/logger.lua +++ b/src/lua/utils/format.lua @@ -1,10 +1,10 @@ --[[ - Logger utilities for BalatroBot - Provides helpers for consistent, readable log output + Format utilities for BalatroBot + Provides helpers for serializing values, params, and cards ]] ----@class BB_LOGGER -local BB_LOGGER = {} +---@class BB_FORMAT +local BB_FORMAT = {} --- Serialize a value for logging (handles tables, strings, etc.) ---@param value any @@ -51,7 +51,7 @@ end --- Examples: "({cards=[0,2,4], deck="RED"})" or "()" ---@param params table|nil ---@return string -function BB_LOGGER.serialize_params(params) +function BB_FORMAT.serialize_params(params) if params == nil or next(params) == nil then return "()" end @@ -67,7 +67,7 @@ end --- Format a playing card as "R♠" style (e.g., "A♠", "K♥", "10♦") ---@param card table The card object with card.base.value and card.base.suit ---@return string -function BB_LOGGER.format_playing_card(card) +function BB_FORMAT.format_playing_card(card) if not card or not card.base then return "?" end @@ -87,13 +87,13 @@ end ---@param cards table[] Array of card objects (1-based Lua array) ---@param indices integer[] Array of 0-based indices ---@return string Comma-separated card strings like "A♠, K♥, Q♦" -function BB_LOGGER.format_playing_cards(cards, indices) +function BB_FORMAT.format_playing_cards(cards, indices) local parts = {} for _, idx in ipairs(indices) do local card = cards[idx + 1] -- 0-based to 1-based - table.insert(parts, BB_LOGGER.format_playing_card(card)) + table.insert(parts, BB_FORMAT.format_playing_card(card)) end return table.concat(parts, ", ") end -return BB_LOGGER +return BB_FORMAT diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua index a7cc2b97..4783266d 100644 --- a/src/lua/utils/gamestate.lua +++ b/src/lua/utils/gamestate.lua @@ -30,57 +30,6 @@ local function get_state_name(state_num) return "UNKNOWN" end --- ========================================================================== --- Deck Name Mapping --- ========================================================================== - -local DECK_KEY_TO_NAME = { - b_red = "RED", - b_blue = "BLUE", - b_yellow = "YELLOW", - b_green = "GREEN", - b_black = "BLACK", - b_magic = "MAGIC", - b_nebula = "NEBULA", - b_ghost = "GHOST", - b_abandoned = "ABANDONED", - b_checkered = "CHECKERED", - b_zodiac = "ZODIAC", - b_painted = "PAINTED", - b_anaglyph = "ANAGLYPH", - b_plasma = "PLASMA", - b_erratic = "ERRATIC", -} - ----Converts deck key to string deck name ----@param deck_key string The key from G.P_CENTERS (e.g., "b_red") ----@return string? deck_name The string name of the deck (e.g., "RED"), or nil if not found -local function get_deck_name(deck_key) - return DECK_KEY_TO_NAME[deck_key] -end - --- ========================================================================== --- Stake Name Mapping --- ========================================================================== - -local STAKE_LEVEL_TO_NAME = { - [1] = "WHITE", - [2] = "RED", - [3] = "GREEN", - [4] = "BLACK", - [5] = "BLUE", - [6] = "PURPLE", - [7] = "ORANGE", - [8] = "GOLD", -} - ----Converts numeric stake level to string stake name ----@param stake_num number The numeric stake value from G.GAME.stake (1-8) ----@return string? stake_name The string name of the stake (e.g., "WHITE"), or nil if not found -local function get_stake_name(stake_num) - return STAKE_LEVEL_TO_NAME[stake_num] -end - -- ========================================================================== -- Card UI Description -- ========================================================================== @@ -229,17 +178,17 @@ local function extract_card_modifier(card) -- Seal (direct property) if card.seal then - modifier.seal = string.upper(card.seal) + modifier.seal = card.seal end - -- Edition (table with type/key) - if card.edition and card.edition.type then - modifier.edition = string.upper(card.edition.type) + -- Edition (table with key) + if card.edition and card.edition.key then + modifier.edition = card.edition.key end - -- Enhancement (from ability.name for enhanced cards) - if card.ability and card.ability.effect and card.ability.effect ~= "Base" then - modifier.enhancement = string.upper(card.ability.effect:gsub(" Card", "")) + -- Enhancement (from center_key for enhanced cards) + if card.config and card.config.center_key and card.config.center_key:sub(1, 2) == "m_" then + modifier.enhancement = card.config.center_key end -- Eternal (boolean from ability) @@ -490,11 +439,11 @@ local function get_blind_effect_from_ui(blind_config) -- Access localization data directly (more reliable than using localize function) -- Path: G.localization.descriptions.Blind[blind_key].text - if not G or not G.localization then ---@diagnostic disable-line: undefined-global + if not G or not G.localization then return "" end - local loc_data = G.localization.descriptions ---@diagnostic disable-line: undefined-global + local loc_data = G.localization.descriptions if not loc_data or not loc_data.Blind or not loc_data.Blind[blind_config.key] then return "" end @@ -515,6 +464,89 @@ local function get_blind_effect_from_ui(blind_config) return table.concat(effect_parts, " ") end +---Strips Balatro color codes from text +---Color codes are in format {C:color}text{} or {X:color}text{} +---@param text string The text with color codes +---@return string clean_text The text without color codes +local function strip_color_codes(text) + if not text then + return "" + end + -- Remove color codes: {C:color_name}, {X:mult}, etc. and closing {} + local result = text:gsub("%b{}", ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + return result +end + +---Gets voucher effect description using the game's localize function +---Uses the same approach as generate_card_ui() in common_events.lua +---@param voucher_key string The voucher key (e.g., "v_overstock_norm") +---@return string effect The effect description +local function get_voucher_effect(voucher_key) + if not voucher_key then + return "" + end + + -- Get voucher config from G.P_CENTERS + local center = G.P_CENTERS and G.P_CENTERS[voucher_key] + if not center then + return "" + end + + -- Build loc_vars based on voucher name (mirrors common_events.lua:2559-2576) + local loc_vars = {} + local name = center.name + + if name == "Overstock" or name == "Overstock Plus" then + -- No vars needed + elseif name == "Tarot Merchant" or name == "Tarot Tycoon" then + loc_vars = { center.config.extra_disp } + elseif name == "Planet Merchant" or name == "Planet Tycoon" then + loc_vars = { center.config.extra_disp } + elseif name == "Hone" or name == "Glow Up" then + loc_vars = { center.config.extra } + elseif name == "Reroll Surplus" or name == "Reroll Glut" then + loc_vars = { center.config.extra } + elseif name == "Grabber" or name == "Nacho Tong" then + loc_vars = { center.config.extra } + elseif name == "Wasteful" or name == "Recyclomancy" then + loc_vars = { center.config.extra } + elseif name == "Seed Money" or name == "Money Tree" then + loc_vars = { center.config.extra / 5 } + elseif name == "Blank" or name == "Antimatter" then + -- No vars needed + elseif name == "Hieroglyph" or name == "Petroglyph" then + loc_vars = { center.config.extra } + elseif name == "Director's Cut" or name == "Retcon" then + loc_vars = { center.config.extra } + elseif name == "Paint Brush" or name == "Palette" then + loc_vars = { center.config.extra } + elseif name == "Telescope" or name == "Observatory" then + loc_vars = { center.config.extra } + elseif name == "Clearance Sale" or name == "Liquidation" then + loc_vars = { center.config.extra } + end + + -- Use localize to get description text + if not localize then + return "" + end + + local text_lines = localize({ + type = "raw_descriptions", + key = voucher_key, + set = "Voucher", + vars = loc_vars, + }) + + if not text_lines or type(text_lines) ~= "table" then + return "" + end + + -- Concatenate and strip color codes + local text = table.concat(text_lines, " ") + return strip_color_codes(text) +end + ---Gets tag information using localize function (same approach as Tag:set_text) ---@param tag_key string The tag key from G.P_TAGS ---@return table tag_info {name: string, effect: string} @@ -525,7 +557,7 @@ local function get_tag_info(tag_key) return result end - if not localize then ---@diagnostic disable-line: undefined-global + if not localize then return result end @@ -562,7 +594,7 @@ local function get_tag_info(tag_key) end -- Use localize with raw_descriptions type (matches Balatro's internal approach) - local text_lines = localize({ type = "raw_descriptions", key = tag_key, set = "Tag", vars = loc_vars }) ---@diagnostic disable-line: undefined-global + local text_lines = localize({ type = "raw_descriptions", key = tag_key, set = "Tag", vars = loc_vars }) if text_lines and type(text_lines) == "table" then result.effect = table.concat(text_lines, " ") end @@ -570,6 +602,29 @@ local function get_tag_info(tag_key) return result end +---Gets all owned tags from G.GAME.tags +---@return Tag[] tags Array of Tag objects +local function get_owned_tags() + local tags = {} + + if not G or not G.GAME or not G.GAME.tags then + return tags + end + + for _, tag in pairs(G.GAME.tags) do + if tag and tag.key then + local tag_info = get_tag_info(tag.key) + table.insert(tags, { + key = tag.key, + name = tag_info.name, + effect = tag_info.effect, + }) + end + end + + return tags +end + ---Converts game blind status to uppercase enum ---@param status string Game status (e.g., "Defeated", "Current", "Select") ---@return string uppercase_status Uppercase status enum (e.g., "DEFEATED", "CURRENT", "SELECT") @@ -600,8 +655,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, big = { type = "BIG", @@ -609,8 +663,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, boss = { type = "BOSS", @@ -618,8 +671,7 @@ function gamestate.get_blinds_info() name = "", effect = "", score = 0, - tag_name = "", - tag_effect = "", + tag = nil, --[[@type Tag?]] }, } @@ -629,7 +681,7 @@ function gamestate.get_blinds_info() -- Get base blind amount for current ante local ante = G.GAME.round_resets.ante or 1 - local base_amount = get_blind_amount(ante) ---@diagnostic disable-line: undefined-global + local base_amount = get_blind_amount(ante) -- Apply ante scaling with null check local ante_scaling = (G.GAME.starting_params and G.GAME.starting_params.ante_scaling) or 1 @@ -642,6 +694,7 @@ function gamestate.get_blinds_info() -- Small Blind -- ==================== local small_choice = blind_choices.Small or "bl_small" + blinds.small.key = small_choice if G.P_BLINDS and G.P_BLINDS[small_choice] then local small_blind = G.P_BLINDS[small_choice] blinds.small.name = small_blind.name or "Small Blind" @@ -657,8 +710,11 @@ function gamestate.get_blinds_info() local small_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Small if small_tag_key then local tag_info = get_tag_info(small_tag_key) - blinds.small.tag_name = tag_info.name - blinds.small.tag_effect = tag_info.effect + blinds.small.tag = { + key = small_tag_key, + name = tag_info.name, + effect = tag_info.effect, + } end end @@ -666,6 +722,7 @@ function gamestate.get_blinds_info() -- Big Blind -- ==================== local big_choice = blind_choices.Big or "bl_big" + blinds.big.key = big_choice if G.P_BLINDS and G.P_BLINDS[big_choice] then local big_blind = G.P_BLINDS[big_choice] blinds.big.name = big_blind.name or "Big Blind" @@ -681,8 +738,11 @@ function gamestate.get_blinds_info() local big_tag_key = G.GAME.round_resets.blind_tags and G.GAME.round_resets.blind_tags.Big if big_tag_key then local tag_info = get_tag_info(big_tag_key) - blinds.big.tag_name = tag_info.name - blinds.big.tag_effect = tag_info.effect + blinds.big.tag = { + key = big_tag_key, + name = tag_info.name, + effect = tag_info.effect, + } end end @@ -690,6 +750,9 @@ function gamestate.get_blinds_info() -- Boss Blind -- ==================== local boss_choice = blind_choices.Boss + if boss_choice then + blinds.boss.key = boss_choice + end if boss_choice and G.P_BLINDS and G.P_BLINDS[boss_choice] then local boss_blind = G.P_BLINDS[boss_choice] blinds.boss.name = boss_blind.name or "Boss Blind" @@ -706,7 +769,7 @@ function gamestate.get_blinds_info() blinds.boss.score = math.floor(base_amount * 2 * ante_scaling) end - -- Boss blind has no tags (tag_name and tag_effect remain empty strings) + -- Boss blind has no tags (tag remains nil) return blinds end @@ -731,6 +794,11 @@ function gamestate.get_gamestate() state = get_state_name(G.STATE), } + -- Pause flag: true while a blocking overlay is up (win screen, pause + -- menu, game over). Exposed so callers can detect a session stuck in a + -- paused state — e.g. endless mode after a win if the overlay is left up. + state_data.paused = G.SETTINGS and G.SETTINGS.paused or false + -- Basic game info if G.GAME then state_data.round_num = G.GAME.round or 0 @@ -741,12 +809,18 @@ function gamestate.get_gamestate() -- Deck (optional) if G.GAME.selected_back and G.GAME.selected_back.effect and G.GAME.selected_back.effect.center then local deck_key = G.GAME.selected_back.effect.center.key - state_data.deck = get_deck_name(deck_key) + state_data.deck = deck_key end -- Stake (optional) if G.GAME.stake then - state_data.stake = get_stake_name(G.GAME.stake) + local stake_level = G.GAME.stake + for key, stake_data in pairs(G.P_STAKES) do + if stake_data.order == stake_level or stake_data.stake_level == stake_level then + state_data.stake = key + break + end + end end -- Seed (optional) @@ -757,16 +831,18 @@ function gamestate.get_gamestate() -- Used vouchers (table<string, string>) if G.GAME.used_vouchers then local used_vouchers = {} - for voucher_name, voucher_data in pairs(G.GAME.used_vouchers) do - if type(voucher_data) == "table" and voucher_data.description then - used_vouchers[voucher_name] = voucher_data.description - else - used_vouchers[voucher_name] = "" - end + for voucher_name, _ in pairs(G.GAME.used_vouchers) do + used_vouchers[voucher_name] = get_voucher_effect(voucher_name) end state_data.used_vouchers = used_vouchers end + -- Owned tags (Tag[]) + local owned_tags = get_owned_tags() + if #owned_tags > 0 then + state_data.tags = owned_tags + end + -- Poker hands if G.GAME.hands then state_data.hands = extract_hand_info(G.GAME.hands) diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json index eaea0856..df3a15c1 100644 --- a/src/lua/utils/openrpc.json +++ b/src/lua/utils/openrpc.json @@ -37,7 +37,7 @@ { "name": "add", "summary": "Add a new card to the game", - "description": "Add a new card to the game (joker, consumable, voucher, or playing card). Playing cards use SUIT_RANK format (e.g., H_A for Ace of Hearts).", + "description": "Add a new card to the game (joker, consumable, voucher, pack, or playing card). Playing cards use SUIT_RANK format (e.g., H_A for Ace of Hearts).", "tags": [ { "$ref": "#/components/tags/cards" @@ -46,7 +46,7 @@ "params": [ { "name": "key", - "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", + "description": "Card key. Format: jokers (j_*), consumables (c_*), vouchers (v_*), packs (p_*), or playing cards (SUIT_RANK like H_A, D_K, C_2, S_T)", "required": true, "schema": { "$ref": "#/components/schemas/CardKey" @@ -62,7 +62,7 @@ }, { "name": "edition", - "description": "Edition type. NEGATIVE only valid for consumables; jokers and playing cards accept all editions. Not valid for vouchers.", + "description": "Edition key (e_foil, e_holo, e_polychrome, e_negative). e_negative only valid for consumables; jokers and playing cards accept all editions. Not valid for vouchers.", "required": false, "schema": { "$ref": "#/components/schemas/Edition" @@ -577,7 +577,7 @@ { "name": "sell", "summary": "Sell a joker or consumable", - "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable.", + "description": "Sell a joker or consumable from player inventory. Must provide exactly one of: joker or consumable. Available in SHOP, SELECTING_HAND states, and when a Buffoon pack is open (SMODS_BOOSTER_OPENED state with Joker set pack) to make room for new jokers.", "tags": [ { "$ref": "#/components/tags/shop" @@ -614,6 +614,9 @@ { "$ref": "#/components/errors/BadRequest" }, + { + "$ref": "#/components/errors/InvalidState" + }, { "$ref": "#/components/errors/NotAllowed" } @@ -690,6 +693,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "boss", + "description": "Override which Boss Blind appears (only in BLIND_SELECT state, boss must be Upcoming)", + "required": false, + "schema": { + "type": "string" + } } ], "result": { @@ -878,6 +889,10 @@ "state": { "$ref": "#/components/schemas/State" }, + "paused": { + "type": "boolean", + "description": "Whether the game is paused by a blocking overlay (win screen, pause menu, game over)" + }, "round_num": { "type": "integer", "description": "Current round number" @@ -913,6 +928,13 @@ "type": "string" } }, + "tags": { + "type": "array", + "description": "Accumulated tags owned by the player", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, "hands": { "type": "object", "description": "Poker hands information", @@ -1053,10 +1075,37 @@ } } }, + "Tag": { + "type": "object", + "description": "Tag information", + "properties": { + "key": { + "type": "string", + "description": "The tag key (e.g., 'tag_polychrome')" + }, + "name": { + "type": "string", + "description": "Display name (e.g., 'Polychrome Tag')" + }, + "effect": { + "type": "string", + "description": "Description of the tag's effect" + } + }, + "required": [ + "key", + "name", + "effect" + ] + }, "Blind": { "type": "object", "description": "Blind information", "properties": { + "key": { + "type": "string", + "description": "Key of the blind (e.g., 'bl_small', 'bl_hook')" + }, "type": { "$ref": "#/components/schemas/BlindType" }, @@ -1075,16 +1124,13 @@ "type": "integer", "description": "Score requirement to beat this blind" }, - "tag_name": { - "type": "string", - "description": "Name of the tag associated with this blind (Small/Big only)" - }, - "tag_effect": { - "type": "string", - "description": "Description of the tag's effect (Small/Big only)" + "tag": { + "$ref": "#/components/schemas/Tag", + "description": "Tag associated with this blind (Small/Big only)" } }, "required": [ + "key", "type", "status", "name", @@ -1339,64 +1385,64 @@ "description": "Deck type", "oneOf": [ { - "const": "RED", - "description": "+1 discard every round" + "const": "b_red", + "description": "Red Deck: +1 discard every round" }, { - "const": "BLUE", - "description": "+1 hand every round" + "const": "b_blue", + "description": "Blue Deck: +1 hand every round" }, { - "const": "YELLOW", - "description": "Start with extra $10" + "const": "b_yellow", + "description": "Yellow Deck: Start with extra $10" }, { - "const": "GREEN", - "description": "$2 per remaining Hand, $1 per remaining Discard, no interest" + "const": "b_green", + "description": "Green Deck: $2 per remaining Hand, $1 per remaining Discard, no interest" }, { - "const": "BLACK", - "description": "+1 Joker slot, -1 hand every round" + "const": "b_black", + "description": "Black Deck: +1 Joker slot, -1 hand every round" }, { - "const": "MAGIC", - "description": "Start with Crystal Ball voucher and 2 copies of The Fool" + "const": "b_magic", + "description": "Magic Deck: Start with Crystal Ball and 2 copies of The Fool" }, { - "const": "NEBULA", - "description": "Start with Telescope voucher, -1 consumable slot" + "const": "b_nebula", + "description": "Nebula Deck: Start with Telescope, -1 consumable slot" }, { - "const": "GHOST", - "description": "Spectral cards may appear in shop, start with Hex card" + "const": "b_ghost", + "description": "Ghost Deck: Spectral cards may appear in shop, start with Hex" }, { - "const": "ABANDONED", - "description": "Start with no Face Cards in deck" + "const": "b_abandoned", + "description": "Abandoned Deck: No Face Cards in starting deck" }, { - "const": "CHECKERED", - "description": "Start with 26 Spades and 26 Hearts in deck" + "const": "b_checkered", + "description": "Checkered Deck: 26 Spades and 26 Hearts in deck" }, { - "const": "ZODIAC", - "description": "Start with Tarot Merchant, Planet Merchant, and Overstock" + "const": "b_zodiac", + "description": "Zodiac Deck: Start with Tarot Merchant, Planet Merchant, and Overstock" }, { - "const": "PAINTED", - "description": "+2 hand size, -1 Joker slot" + "const": "b_painted", + "description": "Painted Deck: +2 hand size, -1 Joker slot" }, { - "const": "ANAGLYPH", - "description": "Gain Double Tag after each Boss Blind" + "const": "b_anaglyph", + "description": "Anaglyph Deck: Double Tag after each Boss Blind" }, { - "const": "PLASMA", - "description": "Balanced Chips and Mult, 2X base Blind size" + "const": "b_plasma", + "description": "Plasma Deck: Balanced Chips/Mult, 2X base Blind size" }, { - "const": "ERRATIC", - "description": "All Ranks and Suits in deck are randomized" + "const": "b_erratic", + "description": "Erratic Deck: Random Ranks and Suits" } ] }, @@ -1404,35 +1450,35 @@ "description": "Stake level", "oneOf": [ { - "const": "WHITE", + "const": "stake_white", "description": "Base Difficulty" }, { - "const": "RED", + "const": "stake_red", "description": "Small Blind gives no reward money" }, { - "const": "GREEN", + "const": "stake_green", "description": "Required scores scale faster for each Ante" }, { - "const": "BLACK", + "const": "stake_black", "description": "Shop can have Eternal Jokers" }, { - "const": "BLUE", + "const": "stake_blue", "description": "-1 Discard" }, { - "const": "PURPLE", + "const": "stake_purple", "description": "Required score scales faster for each Ante" }, { - "const": "ORANGE", + "const": "stake_orange", "description": "Shop can have Perishable Jokers" }, { - "const": "GOLD", + "const": "stake_gold", "description": "Shop can have Rental Jokers" } ] @@ -1519,19 +1565,19 @@ "description": "Card seal type", "oneOf": [ { - "const": "RED", + "const": "Red", "description": "Retrigger this card 1 time" }, { - "const": "BLUE", + "const": "Blue", "description": "Creates Planet card for final played poker hand if held in hand" }, { - "const": "GOLD", + "const": "Gold", "description": "Earn $3 when this card is played and scores" }, { - "const": "PURPLE", + "const": "Purple", "description": "Creates a Tarot card when discarded" } ] @@ -1540,19 +1586,19 @@ "description": "Card edition type", "oneOf": [ { - "const": "FOIL", + "const": "e_foil", "description": "+50 Chips when scored" }, { - "const": "HOLO", + "const": "e_holo", "description": "+10 Mult when scored" }, { - "const": "POLYCHROME", + "const": "e_polychrome", "description": "X1.5 Mult when scored" }, { - "const": "NEGATIVE", + "const": "e_negative", "description": "+1 Joker slot (Jokers) or +1 Consumable slot (Consumables)" } ] @@ -1561,35 +1607,35 @@ "description": "Card enhancement type", "oneOf": [ { - "const": "BONUS", + "const": "m_bonus", "description": "+30 Chips when scored" }, { - "const": "MULT", + "const": "m_mult", "description": "+4 Mult when scored" }, { - "const": "WILD", + "const": "m_wild", "description": "Counts as every suit simultaneously" }, { - "const": "GLASS", + "const": "m_glass", "description": "X2 Mult when scored" }, { - "const": "STEEL", + "const": "m_steel", "description": "X1.5 Mult while held in hand" }, { - "const": "STONE", + "const": "m_stone", "description": "+50 Chips, no rank or suit" }, { - "const": "GOLD", + "const": "m_gold", "description": "$3 if held in hand at end of round" }, { - "const": "LUCKY", + "const": "m_lucky", "description": "1 in 5 chance +20 Mult, 1 in 15 chance $20" } ] diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua index 53f43b13..5c522071 100644 --- a/src/lua/utils/types.lua +++ b/src/lua/utils/types.lua @@ -13,10 +13,12 @@ ---@field stake Stake? Current selected stake ---@field seed string? Seed used for the run ---@field state State Current game state +---@field paused boolean Whether the game is paused by a blocking overlay (win screen, pause menu, game over) ---@field round_num integer Current round number ---@field ante_num integer Current ante number ---@field money integer Current money amount ---@field used_vouchers table<string, string>? Vouchers used (name -> description) +---@field tags Tag[]? Accumulated tags owned by the player ---@field hands table<string, Hand>? Poker hands information ---@field round Round? Current round state ---@field blinds table<"small"|"big"|"boss", Blind>? Blind information @@ -47,14 +49,19 @@ ---@field reroll_cost integer? Current cost to reroll the shop ---@field chips integer? Current chips scored in this round +---@class Tag +---@field key string The tag key (e.g., "tag_polychrome", "tag_double") +---@field name string Display name of the tag (e.g., "Polychrome Tag") +---@field effect string Description of the tag's effect + ---@class Blind +---@field key Blind.Key Key of the blind (e.g., "bl_small", "bl_hook") ---@field type Blind.Type Type of the blind ---@field status Blind.Status Status of the bilnd ---@field name string Name of the blind (e.g., "Small", "Big" or the Boss name) ---@field effect string Description of the blind's effect ---@field score integer Score requirement to beat this blind ----@field tag_name string? Name of the tag associated with this blind (Small/Big only) ----@field tag_effect string? Description of the tag's effect (Small/Big only) +---@field tag Tag? Tag associated with this blind (Small/Big only) ---@class Area ---@field count integer Current number of cards in this area @@ -79,7 +86,7 @@ ---@class Card.Modifier ---@field seal Card.Modifier.Seal? Seal type (playing cards) ----@field edition Card.Modifier.Edition? Edition type (jokers, playing cards and NEGATIVE consumables) +---@field edition Card.Modifier.Edition? Edition type (jokers, playing cards and e_negative consumables) ---@field enhancement Card.Modifier.Enhancement? Enhancement type (playing cards) ---@field eternal boolean? If true, card cannot be sold or destroyed (jokers only) ---@field perishable integer? Number of rounds remaining (only if > 0) (jokers only) @@ -269,18 +276,10 @@ ---@class Settings ---@field host string Hostname for the HTTP server (default: "127.0.0.1") ---@field port integer Port number for the HTTP server (default: 12346) ----@field headless boolean Whether to run in headless mode (minimizes window, disables rendering) ----@field fast boolean Whether to run in fast mode (unlimited FPS, 10x game speed, 60 FPS animations) ----@field render_on_api boolean Whether to render frames only on API calls (mutually exclusive with headless) ----@field audio boolean Whether to play audio (enables sound thread and sets volume levels) +---@field render string Render mode: headfull|headless|ondemand (default: "headfull") ---@field debug boolean Whether debug mode is enabled (requires DebugPlus mod) ----@field no_shaders boolean Whether to disable all shaders for better performance (causes visual glitches) ----@field fps_cap integer Maximum FPS cap for the game (default: 60) ----@field gamespeed integer Game speed multiplier (default: 4) ----@field animation_fps integer Animation FPS (default: 10) ----@field no_reduced_motion boolean Whether to disable reduced motion for faster animations ----@field pixel_art_smoothing boolean Whether to enable pixel art smoothing (texture_scaling = 2) ----@field setup fun()? Initialize and apply all BalatroBot settings +---@field settings string? Settings profile name, e.g. "fast", "turbo", "light" (nil if not provided, defaults to "default" in Lua) +---@field setup fun(): boolean Initialize BalatroBot settings. Returns false if "BalatroBot" profile not selected. ---@class Debug ---@field log table? DebugPlus logger instance with debug/info/error methods (nil if DebugPlus not available) @@ -294,6 +293,8 @@ ---@field current_request_id integer|string|nil Current JSON-RPC 2.0 request ID being processed (nil if no active request) ---@field client_state table? HTTP request parsing state for current client (buffer, headers, etc.) (nil if no client connected) ---@field openrpc_spec string? OpenRPC specification JSON string (loaded at init, nil before init) +---@field req_file file*? File handle for recording JSON-RPC request bodies (nil if logging disabled) +---@field res_file file*? File handle for recording JSON-RPC response bodies (nil if logging disabled) ---@field init? fun(): boolean Initialize HTTP server socket and load OpenRPC spec ---@field accept? fun(): boolean Accept new HTTP client connection ---@field send_response? fun(response: Response.Endpoint): boolean Send JSON-RPC 2.0 response over HTTP to client diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 315dfd23..9e49441d 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -3,14 +3,13 @@ import asyncio import os import random -from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from balatrobot.cli.client import BalatroClient from balatrobot.config import ENV_MAP, Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance # ============================================================================ # Constants @@ -18,14 +17,6 @@ HOST = "127.0.0.1" -# Files that contain integration tests requiring Balatro -INTEGRATION_FILES = { - "test_client.py", - "test_api_cmd.py", - "test_serve_cmd.py", - "test_integration.py", -} - # ============================================================================ # Pytest Hooks for Balatro Instance Management @@ -88,23 +79,6 @@ async def stop_all(): print(f"Error stopping Balatro instances: {e}") -def pytest_collection_modifyitems(items): - """Mark integration test files automatically.""" - current_dir = Path(__file__).parent - - for item in items: - # Only process items in this directory - if ( - current_dir not in Path(item.path).parents - and Path(item.path).parent != current_dir - ): - continue - - # Mark files that need Balatro as integration tests - if item.path.name in INTEGRATION_FILES: - item.add_marker(pytest.mark.integration) - - # ============================================================================ # Session-scoped Fixtures for Integration Tests # ============================================================================ diff --git a/tests/cli/test_api_cmd.py b/tests/cli/test_api_cmd.py index c5e363fc..9df6203a 100644 --- a/tests/cli/test_api_cmd.py +++ b/tests/cli/test_api_cmd.py @@ -1,6 +1,7 @@ """Integration tests for balatrobot api command.""" import json +from pathlib import Path from typer.testing import CliRunner @@ -16,8 +17,10 @@ class TestApiCommand: # --- Happy path tests --- def test_api_health_success(self, cli_port: int): - """api health returns JSON result.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port)]) + """api health returns JSON result with explicit port.""" + result = runner.invoke( + app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "ok" @@ -25,7 +28,9 @@ def test_api_health_success(self, cli_port: int): def test_api_gamestate_success(self, cli_port: int, balatro_client: BalatroClient): """api gamestate returns state.""" balatro_client.call("menu") # Reset state - result = runner.invoke(app, ["api", "gamestate", "--port", str(cli_port)]) + result = runner.invoke( + app, ["api", "gamestate", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 data = json.loads(result.output) assert "state" in data @@ -33,8 +38,11 @@ def test_api_gamestate_success(self, cli_port: int, balatro_client: BalatroClien def test_api_with_params(self, cli_port: int, balatro_client: BalatroClient): """api command passes JSON params correctly.""" balatro_client.call("menu") - params = json.dumps({"deck": "RED", "stake": "WHITE"}) - result = runner.invoke(app, ["api", "start", params, "--port", str(cli_port)]) + params = json.dumps({"deck": "b_red", "stake": "stake_white"}) + result = runner.invoke( + app, + ["api", "start", params, "--port", str(cli_port), "--host", "127.0.0.1"], + ) assert result.exit_code == 0 # --- Method validation tests --- @@ -66,7 +74,9 @@ def test_api_invalid_json_params(self, cli_port: int): def test_api_empty_params_default(self, cli_port: int): """Empty params default to {}.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port)]) + result = runner.invoke( + app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 # --- API error handling tests --- @@ -75,7 +85,16 @@ def test_api_error_formatted(self, cli_port: int, balatro_client: BalatroClient) """API errors formatted as 'Error: NAME - message'.""" balatro_client.call("menu") result = runner.invoke( - app, ["api", "play", '{"cards": [0]}', "--port", str(cli_port)] + app, + [ + "api", + "play", + '{"cards": [0]}', + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], ) assert result.exit_code == 1 assert "Error: INVALID_STATE" in result.output @@ -84,7 +103,9 @@ def test_api_error_formatted(self, cli_port: int, balatro_client: BalatroClient) def test_api_connection_error(self): """Connection error formatted correctly.""" - result = runner.invoke(app, ["api", "health", "--port", "1"]) + result = runner.invoke( + app, ["api", "health", "--port", "1", "--host", "127.0.0.1"] + ) assert result.exit_code == 1 assert "Connection failed" in result.output @@ -92,7 +113,225 @@ def test_api_connection_error(self): def test_api_output_is_indented_json(self, cli_port: int): """Output is pretty-printed JSON.""" - result = runner.invoke(app, ["api", "health", "--port", str(cli_port)]) + result = runner.invoke( + app, ["api", "health", "--port", str(cli_port), "--host", "127.0.0.1"] + ) assert result.exit_code == 0 # Check for indentation (2 spaces) or compact format assert ' "status"' in result.output or '"status": "ok"' in result.output + + # --- Discovery tests --- + + def test_api_no_state_file_error(self, tmp_path, monkeypatch): + """Discovery fails gracefully when no state file.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["api", "health"]) + assert result.exit_code == 1 + + # --- Host/port validation tests --- + + def test_api_host_without_port(self, tmp_path, monkeypatch): + """--host without --port rejected.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["api", "health", "--host", "127.0.0.1"]) + assert result.exit_code == 1 + assert "--host and --port must be provided together" in result.output + + def test_api_port_without_host(self, tmp_path, monkeypatch): + """--port without --host rejected.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["api", "health", "--port", "12346"]) + assert result.exit_code == 1 + assert "--host and --port must be provided together" in result.output + + +# ============================================================================ +# Replay tests (--requests / --responses) +# ============================================================================ + + +class TestReplayCommand: + """Test balatrobot api --requests / --responses replay.""" + + def _write_jsonl(self, path: Path, objects: list[dict]) -> None: + """Write a list of dicts as JSONL.""" + path.write_text("\n".join(json.dumps(o) for o in objects)) + + def test_replay_simple_sequence(self, cli_port: int, balatro_client: BalatroClient): + """Replay a small menu→health sequence → exit 0.""" + balatro_client.call("menu") # reset state + reqs = [ + {"jsonrpc": "2.0", "method": "menu", "params": {}, "id": 1}, + {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 2}, + ] + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in reqs: + f.write(json.dumps(r) + "\n") + req_path = f.name + + try: + result = runner.invoke( + app, + [ + "api", + "--requests", + req_path, + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 0, result.output + assert "Replayed 2 requests successfully" in result.output + finally: + Path(req_path).unlink(missing_ok=True) + + def test_replay_with_matching_responses( + self, cli_port: int, balatro_client: BalatroClient + ): + """Replay + verify with matching responses → exit 0.""" + balatro_client.call("menu") + # Capture actual responses first + actual_result = balatro_client.call("health") + + reqs = [ + {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}, + ] + resps = [ + {"jsonrpc": "2.0", "result": actual_result, "id": 1}, + ] + + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in reqs: + f.write(json.dumps(r) + "\n") + req_path = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in resps: + f.write(json.dumps(r) + "\n") + resp_path = f.name + + try: + result = runner.invoke( + app, + [ + "api", + "--requests", + req_path, + "--responses", + resp_path, + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 0, result.output + assert "Replayed 1 requests successfully" in result.output + finally: + Path(req_path).unlink(missing_ok=True) + Path(resp_path).unlink(missing_ok=True) + + def test_replay_with_diverging_responses( + self, cli_port: int, balatro_client: BalatroClient + ): + """Replay + verify with diverging responses → exit 1.""" + balatro_client.call("menu") + + reqs = [ + {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}, + ] + resps = [ + {"jsonrpc": "2.0", "result": {"status": "WRONG"}, "id": 1}, + ] + + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in reqs: + f.write(json.dumps(r) + "\n") + req_path = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + for r in resps: + f.write(json.dumps(r) + "\n") + resp_path = f.name + + try: + result = runner.invoke( + app, + [ + "api", + "--requests", + req_path, + "--responses", + resp_path, + "--port", + str(cli_port), + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 1, result.output + assert "Divergence" in result.output + finally: + Path(req_path).unlink(missing_ok=True) + Path(resp_path).unlink(missing_ok=True) + + def test_replay_empty_requests_file(self, tmp_path): + """Empty requests file → error.""" + req_file = tmp_path / "empty.jsonl" + req_file.write_text("") + + result = runner.invoke( + app, + ["api", "--requests", str(req_file), "--port", "1", "--host", "127.0.0.1"], + ) + assert result.exit_code == 1 + assert "empty" in result.output.lower() + + def test_replay_malformed_json_line(self, tmp_path): + """Malformed JSON line → error with line number.""" + req_file = tmp_path / "bad.jsonl" + req_file.write_text("{not valid json\n") + + result = runner.invoke( + app, + ["api", "--requests", str(req_file), "--port", "1", "--host", "127.0.0.1"], + ) + assert result.exit_code == 1 + assert "line 1" in result.output.lower() + + def test_replay_response_count_mismatch(self, tmp_path): + """Response count mismatch → error.""" + req_file = tmp_path / "req.jsonl" + req_file.write_text( + json.dumps({"jsonrpc": "2.0", "method": "health", "params": {}, "id": 1}) + + "\n" + + json.dumps({"jsonrpc": "2.0", "method": "health", "params": {}, "id": 2}) + + "\n" + ) + resp_file = tmp_path / "res.jsonl" + resp_file.write_text( + json.dumps({"jsonrpc": "2.0", "result": {"status": "ok"}, "id": 1}) + "\n" + ) + + result = runner.invoke( + app, + [ + "api", + "--requests", + str(req_file), + "--responses", + str(resp_file), + "--port", + "1", + "--host", + "127.0.0.1", + ], + ) + assert result.exit_code == 1 + assert "mismatch" in result.output.lower() diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index a27acf7b..25b8c97e 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -1,10 +1,8 @@ """Tests for balatrobot.config module.""" -from argparse import Namespace - import pytest -from balatrobot.config import Config, _parse_env_value +from balatrobot.config import RENDER_CHOICES, Config, _parse_env_value class TestParseEnvValue: @@ -12,30 +10,20 @@ class TestParseEnvValue: def test_bool_true_values(self): """Boolean fields convert '1' and 'true' to True.""" - assert _parse_env_value("fast", "1") is True - assert _parse_env_value("fast", "true") is True - assert _parse_env_value("headless", "1") is True + assert _parse_env_value("debug", "1") is True + assert _parse_env_value("debug", "true") is True def test_bool_false_values(self): """Boolean fields convert other values to False.""" - assert _parse_env_value("fast", "0") is False - assert _parse_env_value("fast", "false") is False - assert _parse_env_value("fast", "yes") is False - - def test_int_valid(self): - """Integer fields parse valid numbers.""" - assert _parse_env_value("port", "12346") == 12346 - assert _parse_env_value("port", "9999") == 9999 - - def test_int_invalid(self): - """Integer fields raise ValueError for invalid input.""" - with pytest.raises(ValueError): - _parse_env_value("port", "abc") + assert _parse_env_value("debug", "0") is False + assert _parse_env_value("debug", "false") is False + assert _parse_env_value("debug", "yes") is False def test_string_passthrough(self): """String fields pass through unchanged.""" assert _parse_env_value("host", "localhost") == "localhost" - assert _parse_env_value("balatro_path", "/path/to/game") == "/path/to/game" + assert _parse_env_value("render", "headless") == "headless" + assert _parse_env_value("settings", "fast") == "fast" class TestConfigDefaults: @@ -47,85 +35,29 @@ def test_defaults(self, clean_env): assert config.host == "127.0.0.1" assert config.port == 12346 - assert config.fast is False - assert config.headless is False - assert config.logs_path == "logs" - assert config.balatro_path is None - - -class TestConfigFromArgs: - """Tests for Config.from_args() method.""" - - def test_cli_args_used(self, clean_env): - """CLI arguments are used when provided.""" - args = Namespace( - host="0.0.0.0", - port=9999, - fast=True, - headless=None, - render_on_api=None, - audio=None, - debug=None, - no_shaders=None, - balatro_path=None, - lovely_path=None, - love_path=None, - platform=None, - logs_path=None, - ) - config = Config.from_args(args) + assert config.render == "headfull" + assert config.debug is False + assert config.settings is None + assert config.path_logs is None + assert config.path_balatro is None + assert config.path_lovely is None + assert config.path_love is None + assert config.platform is None - assert config.host == "0.0.0.0" - assert config.port == 9999 - assert config.fast is True - - def test_cli_overrides_env(self, clean_env, monkeypatch): - """CLI args override environment variables.""" - monkeypatch.setenv("BALATROBOT_PORT", "8888") - - args = Namespace( - host=None, - port=9999, - fast=None, - headless=None, - render_on_api=None, - audio=None, - debug=None, - no_shaders=None, - balatro_path=None, - lovely_path=None, - love_path=None, - platform=None, - logs_path=None, - ) - config = Config.from_args(args) - - assert config.port == 9999 # CLI wins over env - - def test_env_fallback(self, clean_env, monkeypatch): - """Environment variables used when CLI args are None.""" - monkeypatch.setenv("BALATROBOT_PORT", "8888") - monkeypatch.setenv("BALATROBOT_FAST", "1") - - args = Namespace( - host=None, - port=None, - fast=None, - headless=None, - render_on_api=None, - audio=None, - debug=None, - no_shaders=None, - balatro_path=None, - lovely_path=None, - love_path=None, - platform=None, - logs_path=None, - ) - config = Config.from_args(args) - assert config.port == 8888 - assert config.fast is True +class TestConfigRenderValidation: + """Tests for render mode validation in Config.""" + + def test_valid_render_modes_accepted(self): + """All RENDER_CHOICES produce valid configs.""" + for mode in RENDER_CHOICES: + config = Config(render=mode) + assert config.render == mode + + def test_invalid_render_mode_rejected(self): + """Invalid render mode raises ValueError.""" + with pytest.raises(ValueError, match="Invalid render mode"): + Config(render="invalid") class TestConfigFromEnv: @@ -133,15 +65,14 @@ class TestConfigFromEnv: def test_loads_env_vars(self, clean_env, monkeypatch): """Loads configuration from environment variables.""" - monkeypatch.setenv("BALATROBOT_PORT", "9999") monkeypatch.setenv("BALATROBOT_HOST", "0.0.0.0") - monkeypatch.setenv("BALATROBOT_FAST", "1") + monkeypatch.setenv("BALATROBOT_DEBUG", "1") config = Config.from_env() - assert config.port == 9999 + assert config.port == 12346 assert config.host == "0.0.0.0" - assert config.fast is True + assert config.debug is True def test_defaults_when_no_env(self, clean_env): """Uses defaults when no env vars set.""" @@ -150,30 +81,88 @@ def test_defaults_when_no_env(self, clean_env): assert config.port == 12346 assert config.host == "127.0.0.1" + def test_render_from_env(self, clean_env, monkeypatch): + """Render mode loaded from environment.""" + monkeypatch.setenv("BALATROBOT_RENDER", "headless") + + config = Config.from_env() + + assert config.render == "headless" + + def test_settings_from_env(self, clean_env, monkeypatch): + """Settings profile name loaded from environment.""" + monkeypatch.setenv("BALATROBOT_SETTINGS", "headless") + + config = Config.from_env() + + assert config.settings == "headless" + + def test_path_fields_use_new_names(self, clean_env, monkeypatch): + """New path field names work from environment.""" + monkeypatch.setenv("BALATROBOT_PATH_BALATRO", "/balatro") + monkeypatch.setenv("BALATROBOT_PATH_LOVELY", "/lovely") + monkeypatch.setenv("BALATROBOT_PATH_LOVE", "/love") + monkeypatch.setenv("BALATROBOT_PATH_LOGS", "/logs") + + config = Config.from_env() + + assert config.path_balatro == "/balatro" + assert config.path_lovely == "/lovely" + assert config.path_love == "/love" + assert config.path_logs == "/logs" + class TestConfigToEnv: """Tests for Config.to_env() method.""" def test_serializes_values(self): """Serializes config to environment dict.""" - config = Config(port=9999, fast=True, host="0.0.0.0") + config = Config(port=9999, debug=True, host="0.0.0.0") env = config.to_env() assert env["BALATROBOT_PORT"] == "9999" - assert env["BALATROBOT_FAST"] == "1" + assert env["BALATROBOT_DEBUG"] == "1" assert env["BALATROBOT_HOST"] == "0.0.0.0" def test_skips_none_values(self): """None values are not included.""" - config = Config(balatro_path=None) + config = Config(path_balatro=None) env = config.to_env() - assert "BALATROBOT_BALATRO_PATH" not in env + assert "BALATROBOT_PATH_BALATRO" not in env def test_skips_false_bools(self): """False boolean values are not included.""" - config = Config(fast=False, headless=False) + config = Config(debug=False) + env = config.to_env() + + assert "BALATROBOT_DEBUG" not in env + + def test_includes_render(self): + """Render mode is included in env output.""" + config = Config(render="headless") + env = config.to_env() + + assert env["BALATROBOT_RENDER"] == "headless" + + def test_includes_settings(self): + """Settings profile name is included in env output.""" + config = Config(settings="fast") + env = config.to_env() + + assert env["BALATROBOT_SETTINGS"] == "fast" + + def test_uses_new_env_var_names(self): + """Uses BALATROBOT_PATH_* naming convention.""" + config = Config( + path_balatro="/balatro", + path_lovely="/lovely", + path_love="/love", + path_logs="/logs", + ) env = config.to_env() - assert "BALATROBOT_FAST" not in env - assert "BALATROBOT_HEADLESS" not in env + assert env["BALATROBOT_PATH_BALATRO"] == "/balatro" + assert env["BALATROBOT_PATH_LOVELY"] == "/lovely" + assert env["BALATROBOT_PATH_LOVE"] == "/love" + assert env["BALATROBOT_PATH_LOGS"] == "/logs" diff --git a/tests/cli/test_manager.py b/tests/cli/test_instance.py similarity index 70% rename from tests/cli/test_manager.py rename to tests/cli/test_instance.py index 84910d4d..53e26880 100644 --- a/tests/cli/test_manager.py +++ b/tests/cli/test_instance.py @@ -1,4 +1,4 @@ -"""Tests for balatrobot.manager module.""" +"""Tests for balatrobot.instance module.""" import asyncio from unittest.mock import AsyncMock, MagicMock @@ -6,7 +6,7 @@ import pytest from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance, InstanceDiedError class TestBalatroInstanceInit: @@ -25,8 +25,8 @@ def test_init_with_config(self): def test_init_with_overrides(self): """Overrides apply to base config.""" - config = Config(port=8888, fast=False) - instance = BalatroInstance(config, port=9999, fast=True) + config = Config(port=8888) + instance = BalatroInstance(config, port=9999) assert instance.port == 9999 def test_init_overrides_without_config(self): @@ -162,15 +162,64 @@ async def mock_start(config, session_dir): mock_launcher.build_env = MagicMock(return_value={}) mock_launcher.build_cmd = MagicMock(return_value=["echo"]) - monkeypatch.setattr("balatrobot.manager.get_launcher", lambda x: mock_launcher) + monkeypatch.setattr("balatrobot.instance.get_launcher", lambda x: mock_launcher) - instance = BalatroInstance(logs_path=str(tmp_path)) + instance = BalatroInstance(path_logs=str(tmp_path)) # Mock health check to succeed immediately - instance._wait_for_health = AsyncMock() # type: ignore[assignment] + instance._wait_for_health = AsyncMock() # ty: ignore[invalid-assignment] async with instance: assert instance._process is mock_process # After exit, process should be cleared assert instance._process is None + + +class TestBalatroInstanceCheckAlive: + """Tests for BalatroInstance.check_alive() method.""" + + def test_check_alive_healthy(self): + """No exception when process is running (poll returns None).""" + instance = BalatroInstance(port=14001) + mock_process = MagicMock() + mock_process.poll.return_value = None + instance._process = mock_process + + instance.check_alive() # Should not raise + + def test_check_alive_dead(self): + """Raises InstanceDiedError when process has exited.""" + instance = BalatroInstance(port=14001) + mock_process = MagicMock() + mock_process.poll.return_value = 1 # Exit code 1 + instance._process = mock_process + instance._log_path = MagicMock() + instance._log_path.__str__ = lambda self: "/tmp/test/14001.log" + + with pytest.raises(InstanceDiedError) as exc_info: + instance.check_alive() + assert exc_info.value.port == 14001 + assert "14001" in str(exc_info.value) + + def test_check_alive_dead_with_log_path(self): + """InstanceDiedError includes log_path in message.""" + from pathlib import Path + + instance = BalatroInstance(port=14002) + mock_process = MagicMock() + mock_process.poll.return_value = 0 + instance._process = mock_process + instance._log_path = Path("/tmp/logs/session/14002.log") + + with pytest.raises(InstanceDiedError) as exc_info: + instance.check_alive() + assert exc_info.value.port == 14002 + assert exc_info.value.log_path == "/tmp/logs/session/14002.log" + assert "/tmp/logs/session/14002.log" in str(exc_info.value) + + def test_check_alive_not_started(self): + """No exception when _process is None (not started).""" + instance = BalatroInstance(port=14001) + assert instance._process is None + instance.check_alive() # Should not raise diff --git a/tests/cli/test_integration.py b/tests/cli/test_integration.py index 498941f9..5f4d721e 100644 --- a/tests/cli/test_integration.py +++ b/tests/cli/test_integration.py @@ -13,7 +13,6 @@ def _random_port() -> int: return random.randint(20000, 30000) -@pytest.mark.integration class TestBalatroIntegration: """Integration tests that require a running Balatro instance.""" @@ -21,7 +20,7 @@ class TestBalatroIntegration: async def test_context_manager_lifecycle(self, tmp_path): """Context manager starts and stops Balatro properly.""" async with BalatroInstance( - port=_random_port(), fast=True, headless=True, logs_path=str(tmp_path) + port=_random_port(), render="headless", path_logs=str(tmp_path) ) as instance: # Instance should be running assert instance.process is not None @@ -44,7 +43,7 @@ async def test_context_manager_lifecycle(self, tmp_path): async def test_health_endpoint_responds(self, tmp_path): """Health endpoint returns valid JSON-RPC response.""" async with BalatroInstance( - port=_random_port(), fast=True, headless=True, logs_path=str(tmp_path) + port=_random_port(), render="headless", path_logs=str(tmp_path) ) as instance: url = f"http://127.0.0.1:{instance.port}" payload = {"jsonrpc": "2.0", "method": "health", "params": {}, "id": 42} diff --git a/tests/cli/test_list_cmd.py b/tests/cli/test_list_cmd.py new file mode 100644 index 00000000..92117418 --- /dev/null +++ b/tests/cli/test_list_cmd.py @@ -0,0 +1,83 @@ +"""Tests for balatrobot cli list command.""" + +import json +import os + +from typer.testing import CliRunner + +from balatrobot.cli import app + +runner = CliRunner() + + +class TestListCommand: + """Test balatrobot list command.""" + + def test_list_no_state_file(self, tmp_path, monkeypatch): + """List shows message when no state file exists.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "No running instances" in result.output + + def test_list_with_instances(self, tmp_path, monkeypatch): + """List shows running instances.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + # Write a valid state file + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "14001" in result.output + assert "14002" in result.output + assert "/tmp/logs/s/14001.log" in result.output + assert "/tmp/logs/s/14002.log" in result.output + + def test_list_json_output(self, tmp_path, monkeypatch): + """List --json outputs structured JSON.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = runner.invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["instances"]) == 1 + assert data["instances"][0]["port"] == 14001 + assert data["instances"][0]["log_path"] == "/tmp/logs/s/14001.log" + + def test_list_help(self): + """List --help shows options.""" + result = runner.invoke(app, ["list", "--help"]) + assert result.exit_code == 0 + assert "--json" in result.output diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index cc77ff8a..91891cdc 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -59,7 +59,7 @@ class TestMacOSLauncher: def test_validate_paths_missing_love(self, tmp_path): """Raises RuntimeError when love executable missing.""" launcher = MacOSLauncher() - config = Config(love_path=str(tmp_path / "nonexistent")) + config = Config(path_love=str(tmp_path / "nonexistent")) with pytest.raises(RuntimeError, match="LOVE executable not found"): launcher.validate_paths(config) @@ -67,13 +67,13 @@ def test_validate_paths_missing_love(self, tmp_path): def test_validate_paths_missing_lovely(self, tmp_path): """Raises RuntimeError when liblovely.dylib missing.""" # Create a fake love executable - love_path = tmp_path / "love" - love_path.touch() + path_love = tmp_path / "love" + path_love.touch() launcher = MacOSLauncher() config = Config( - love_path=str(love_path), - lovely_path=str(tmp_path / "nonexistent.dylib"), + path_love=str(path_love), + path_lovely=str(tmp_path / "nonexistent.dylib"), ) with pytest.raises(RuntimeError, match="liblovely.dylib not found"): @@ -82,7 +82,7 @@ def test_validate_paths_missing_lovely(self, tmp_path): def test_build_env_includes_dyld(self, tmp_path): """build_env includes DYLD_INSERT_LIBRARIES.""" launcher = MacOSLauncher() - config = Config(lovely_path="/path/to/liblovely.dylib") + config = Config(path_lovely="/path/to/liblovely.dylib") env = launcher.build_env(config) @@ -91,7 +91,7 @@ def test_build_env_includes_dyld(self, tmp_path): def test_build_cmd(self, tmp_path): """build_cmd returns love executable path.""" launcher = MacOSLauncher() - config = Config(love_path="/path/to/love") + config = Config(path_love="/path/to/love") cmd = launcher.build_cmd(config) @@ -144,8 +144,8 @@ def test_validate_paths_auto_detects_balatro(self, tmp_path, monkeypatch): config = Config() launcher.validate_paths(config) - assert config.balatro_path is not None - assert "Balatro" in config.balatro_path + assert config.path_balatro is not None + assert "Balatro" in config.path_balatro def test_validate_paths_missing_balatro_exe(self, tmp_path, monkeypatch): """Raises RuntimeError when Balatro.exe is missing.""" @@ -194,8 +194,8 @@ def test_build_cmd(self): """build_cmd returns proton run with Balatro.exe.""" launcher = LinuxLauncher() config = Config( - love_path="/path/to/proton", - balatro_path="/path/to/Balatro", + path_love="/path/to/proton", + path_balatro="/path/to/Balatro", ) cmd = launcher.build_cmd(config) assert cmd == ["/path/to/proton", "run", "/path/to/Balatro/Balatro.exe"] @@ -209,8 +209,8 @@ def test_validate_paths_missing_love(self, tmp_path): """Raises RuntimeError when love executable missing.""" launcher = NativeLauncher() config = Config( - love_path=str(tmp_path / "nonexistent"), - balatro_path=str(tmp_path), + path_love=str(tmp_path / "nonexistent"), + path_balatro=str(tmp_path), ) with pytest.raises(RuntimeError, match="LOVE executable not found"): @@ -219,7 +219,7 @@ def test_validate_paths_missing_love(self, tmp_path): def test_build_env_includes_ld_preload(self, tmp_path): """build_env includes LD_PRELOAD.""" launcher = NativeLauncher() - config = Config(lovely_path="/path/to/liblovely.so") + config = Config(path_lovely="/path/to/liblovely.so") env = launcher.build_env(config) @@ -228,7 +228,7 @@ def test_build_env_includes_ld_preload(self, tmp_path): def test_build_cmd(self, tmp_path): """build_cmd returns love and balatro path.""" launcher = NativeLauncher() - config = Config(love_path="/usr/bin/love", balatro_path="/path/to/balatro") + config = Config(path_love="/usr/bin/love", path_balatro="/path/to/balatro") cmd = launcher.build_cmd(config) @@ -242,7 +242,7 @@ class TestWindowsLauncher: def test_validate_paths_missing_balatro_exe(self, tmp_path): """Raises RuntimeError when Balatro.exe missing.""" launcher = WindowsLauncher() - config = Config(love_path=str(tmp_path / "nonexistent.exe")) + config = Config(path_love=str(tmp_path / "nonexistent.exe")) with pytest.raises(RuntimeError, match="Balatro executable not found"): launcher.validate_paths(config) @@ -255,8 +255,8 @@ def test_validate_paths_missing_version_dll(self, tmp_path): launcher = WindowsLauncher() config = Config( - love_path=str(exe_path), - lovely_path=str(tmp_path / "nonexistent.dll"), + path_love=str(exe_path), + path_lovely=str(tmp_path / "nonexistent.dll"), ) with pytest.raises(RuntimeError, match="version.dll not found"): @@ -265,7 +265,7 @@ def test_validate_paths_missing_version_dll(self, tmp_path): def test_build_env_no_dll_injection_var(self, tmp_path): """build_env does not include DLL injection environment variable.""" launcher = WindowsLauncher() - config = Config(lovely_path=r"C:\path\to\version.dll") + config = Config(path_lovely=r"C:\path\to\version.dll") env = launcher.build_env(config) @@ -275,7 +275,7 @@ def test_build_env_no_dll_injection_var(self, tmp_path): def test_build_cmd(self, tmp_path): """build_cmd returns Balatro.exe path.""" launcher = WindowsLauncher() - config = Config(love_path=r"C:\path\to\Balatro.exe") + config = Config(path_love=r"C:\path\to\Balatro.exe") cmd = launcher.build_cmd(config) diff --git a/tests/cli/test_pool.py b/tests/cli/test_pool.py new file mode 100644 index 00000000..a186781b --- /dev/null +++ b/tests/cli/test_pool.py @@ -0,0 +1,379 @@ +"""Tests for balatrobot.pool module.""" + +from dataclasses import FrozenInstanceError +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from balatrobot.config import Config +from balatrobot.instance import BalatroInstance, InstanceDiedError, InstanceInfo +from balatrobot.pool import BalatroPool + +# ============================================================================ +# InstanceInfo tests +# ============================================================================ + + +class TestInstanceInfo: + """Tests for InstanceInfo frozen dataclass.""" + + def test_create_with_host_port(self): + """InstanceInfo stores host and port.""" + info = InstanceInfo(host="127.0.0.1", port=12346) + assert info.host == "127.0.0.1" + assert info.port == 12346 + assert info.log_path is None + + def test_create_with_log_path(self): + """InstanceInfo stores log_path.""" + info = InstanceInfo( + host="127.0.0.1", port=12346, log_path=Path("/tmp/test.log") + ) + assert info.log_path == Path("/tmp/test.log") + + def test_url_property(self): + """url property returns formatted URL.""" + info = InstanceInfo(host="0.0.0.0", port=9999) + assert info.url == "http://0.0.0.0:9999" + + def test_frozen(self): + """InstanceInfo is immutable.""" + info = InstanceInfo(host="127.0.0.1", port=12346) + with pytest.raises(FrozenInstanceError): + setattr(info, "port", 9999) + + def test_equality(self): + """Two InstanceInfo with same values are equal.""" + a = InstanceInfo(host="127.0.0.1", port=12346) + b = InstanceInfo(host="127.0.0.1", port=12346) + assert a == b + + def test_inequality(self): + """Different port means different InstanceInfo.""" + a = InstanceInfo(host="127.0.0.1", port=12346) + b = InstanceInfo(host="127.0.0.1", port=9999) + assert a != b + + +# ============================================================================ +# BalatroPool tests +# ============================================================================ + + +class TestBalatroPoolInit: + """Tests for BalatroPool initialization.""" + + def test_init_defaults(self): + """Pool defaults to n=1, no ports.""" + config = Config() + pool = BalatroPool(config) + assert pool.n == 1 + assert pool.is_started is False + assert pool.instances == [] + + def test_init_with_n(self): + """Pool accepts n parameter.""" + config = Config() + pool = BalatroPool(config, n=3) + assert pool.n == 3 + + def test_init_with_ports(self): + """Pool accepts explicit ports list.""" + config = Config() + pool = BalatroPool(config, ports=[10000, 10001, 10002]) + assert pool.n == 3 + + def test_init_ports_override_n(self): + """Ports length overrides n.""" + config = Config() + pool = BalatroPool(config, n=5, ports=[10000, 10001]) + assert pool.n == 2 + + +class TestBalatroPoolStartStop: + """Tests for BalatroPool start/stop lifecycle.""" + + @pytest.mark.asyncio + async def test_start_creates_instances(self, tmp_path, monkeypatch): + """start() creates n instances and populates instances list.""" + config = Config(path_logs=str(tmp_path)) + + # Mock BalatroInstance + mock_inst = AsyncMock(spec=BalatroInstance) + mock_inst.port = 12346 + mock_inst._config = config + mock_inst.start = AsyncMock() + + created_instances = [] + + def mock_instance_factory(config_arg, **kwargs): + inst = MagicMock(spec=BalatroInstance) + port = kwargs.get("port", 12346) + inst.port = port + inst.log_path = Path(f"/tmp/test-logs/{port}.log") + inst.start = AsyncMock() + inst.stop = AsyncMock() + created_instances.append(inst) + return inst + + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + assert pool.is_started is True + assert len(pool.instances) == 2 + assert pool.instances[0].port == 14001 + assert pool.instances[1].port == 14002 + + await pool.stop() + + @pytest.mark.asyncio + async def test_stop_concurrent(self, tmp_path): + """stop() stops all instances concurrently.""" + config = Config(path_logs=str(tmp_path)) + + mock_instances = [] + for port in [14001, 14002]: + inst = MagicMock(spec=BalatroInstance) + inst.port = port + inst.log_path = Path(f"/tmp/test-logs/{port}.log") + inst.start = AsyncMock() + inst.stop = AsyncMock() + mock_instances.append(inst) + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instances): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + await pool.stop() + + assert pool.is_started is False + for inst in mock_instances: + inst.stop.assert_called_once() + + @pytest.mark.asyncio + async def test_start_fail_cleans_up(self, tmp_path): + """If one instance fails to start, all are stopped.""" + config = Config(path_logs=str(tmp_path)) + + started_inst = MagicMock(spec=BalatroInstance) + started_inst.port = 14001 + started_inst.log_path = Path("/tmp/test-logs/14001.log") + started_inst.start = AsyncMock() + started_inst.stop = AsyncMock() + + failed_inst = MagicMock(spec=BalatroInstance) + failed_inst.port = 14002 + failed_inst.log_path = Path("/tmp/test-logs/14002.log") + failed_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) + failed_inst.stop = AsyncMock() + + with patch( + "balatrobot.pool.BalatroInstance", side_effect=[started_inst, failed_inst] + ): + pool = BalatroPool(config, ports=[14001, 14002]) + with pytest.raises(RuntimeError, match="start failed"): + await pool.start() + + # Started instance should have been stopped + started_inst.stop.assert_called_once() + assert pool.is_started is False + assert pool.instances == [] + + @pytest.mark.asyncio + async def test_stop_idempotent(self, tmp_path): + """stop() is safe to call when not started.""" + config = Config(path_logs=str(tmp_path)) + pool = BalatroPool(config) + await pool.stop() # Should not raise + + @pytest.mark.asyncio + async def test_start_already_started(self, tmp_path): + """start() raises if already started.""" + config = Config(path_logs=str(tmp_path)) + pool = BalatroPool(config) + + mock_inst = MagicMock(spec=BalatroInstance) + mock_inst.port = 14001 + mock_inst.log_path = Path("/tmp/test-logs/14001.log") + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with patch("balatrobot.pool.BalatroInstance", return_value=mock_inst): + await pool.start() + with pytest.raises(RuntimeError, match="already started"): + await pool.start() + await pool.stop() + + @pytest.mark.asyncio + async def test_instances_populated_after_start(self, tmp_path): + """instances returns InstanceInfo list after start.""" + config = Config(path_logs=str(tmp_path)) + + mock_instances = [] + for port in [14001, 14002]: + inst = MagicMock(spec=BalatroInstance) + inst.port = port + inst.log_path = Path(f"/tmp/test-logs/{port}.log") + inst.start = AsyncMock() + inst.stop = AsyncMock() + mock_instances.append(inst) + + with patch("balatrobot.pool.BalatroInstance", side_effect=mock_instances): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + infos = pool.instances + assert len(infos) == 2 + assert all(isinstance(i, InstanceInfo) for i in infos) + assert infos[0].port == 14001 + assert infos[1].port == 14002 + assert infos[0].host == config.host + assert infos[0].log_path == Path("/tmp/test-logs/14001.log") + assert infos[1].log_path == Path("/tmp/test-logs/14002.log") + + await pool.stop() + + +class TestBalatroPoolCheckAlive: + """Tests for BalatroPool.check_alive() method.""" + + def test_check_alive_all_healthy(self): + """No exception when all instances are alive.""" + config = Config() + pool = BalatroPool(config, ports=[14001, 14002]) + + mock_inst1 = MagicMock(spec=BalatroInstance) + mock_inst1.check_alive = MagicMock() + mock_inst2 = MagicMock(spec=BalatroInstance) + mock_inst2.check_alive = MagicMock() + + pool._instances = [mock_inst1, mock_inst2] + pool.check_alive() # Should not raise + + mock_inst1.check_alive.assert_called_once() + mock_inst2.check_alive.assert_called_once() + + def test_check_alive_one_dead(self): + """Raises InstanceDiedError from first dead instance.""" + config = Config() + pool = BalatroPool(config, ports=[14001, 14002]) + + mock_inst1 = MagicMock(spec=BalatroInstance) + mock_inst1.check_alive = MagicMock() + mock_inst2 = MagicMock(spec=BalatroInstance) + mock_inst2.check_alive = MagicMock( + side_effect=InstanceDiedError(port=14002, log_path="/tmp/14002.log") + ) + + pool._instances = [mock_inst1, mock_inst2] + with pytest.raises(InstanceDiedError) as exc_info: + pool.check_alive() + assert exc_info.value.port == 14002 + + def test_check_alive_empty_pool(self): + """No exception when pool has no instances.""" + config = Config() + pool = BalatroPool(config) + pool._instances = [] + pool.check_alive() # Should not raise + + +class TestBalatroPoolPortAllocation: + """Tests for automatic port allocation.""" + + @pytest.mark.asyncio + async def test_auto_allocate_ports(self, tmp_path): + """Pool allocates ports automatically when none specified.""" + config = Config(path_logs=str(tmp_path)) + pool = BalatroPool(config, n=2) + + captured_ports = [] + + def mock_instance_factory(config_arg, **kwargs): + port = kwargs.get("port") + captured_ports.append(port) + inst = MagicMock(spec=BalatroInstance) + inst.port = port + inst.log_path = Path(f"/tmp/test-logs/{port}.log") + inst.start = AsyncMock() + inst.stop = AsyncMock() + return inst + + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): + await pool.start() + + assert len(captured_ports) == 2 + assert captured_ports[0] != captured_ports[1] + assert all(isinstance(p, int) for p in captured_ports) + + await pool.stop() + + +class TestBalatroPoolConfigDerivation: + """Tests for pool config derivation.""" + + @pytest.mark.asyncio + async def test_derives_config_per_instance(self, tmp_path): + """Pool derives configs from base config, each with unique port.""" + config = Config(host="0.0.0.0", path_logs=str(tmp_path)) + + captured_configs = [] + captured_overrides = [] + + def mock_instance_factory(config_arg, **kwargs): + captured_configs.append(config_arg) + captured_overrides.append(kwargs) + inst = MagicMock(spec=BalatroInstance) + inst.port = kwargs.get("port", 12346) + inst.log_path = Path(f"/tmp/test-logs/{kwargs.get('port', 12346)}.log") + inst.start = AsyncMock() + inst.stop = AsyncMock() + return inst + + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + # All configs should share host/path_logs + assert all(c.host == "0.0.0.0" for c in captured_configs) + # Each gets a different port + assert captured_overrides[0]["port"] == 14001 + assert captured_overrides[1]["port"] == 14002 + + await pool.stop() + + @pytest.mark.asyncio + async def test_shared_session_name(self, tmp_path): + """Pool generates a shared session name for all instances.""" + config = Config(path_logs=str(tmp_path)) + + captured_session_names = [] + + def mock_instance_factory(config_arg, **kwargs): + session_name = kwargs.get("session_name") + captured_session_names.append(session_name) + inst = MagicMock(spec=BalatroInstance) + inst.port = kwargs.get("port", 12346) + inst.log_path = Path(f"/tmp/test-logs/{kwargs.get('port', 12346)}.log") + inst.start = AsyncMock() + inst.stop = AsyncMock() + return inst + + with patch( + "balatrobot.pool.BalatroInstance", side_effect=mock_instance_factory + ): + pool = BalatroPool(config, ports=[14001, 14002]) + await pool.start() + + # All instances share the same session name + assert len(set(captured_session_names)) == 1 + assert captured_session_names[0] is not None + + await pool.stop() diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index 95bf4aae..228e38bb 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -1,5 +1,6 @@ """Integration tests for balatrobot serve command.""" +import pytest from typer.testing import CliRunner from balatrobot.cli import app @@ -24,17 +25,105 @@ def test_serve_valid_platforms(self): """All valid platforms in list.""" assert PLATFORM_CHOICES == ["darwin", "linux", "windows", "native"] + # --- Num instances validation tests --- + + def test_serve_num_instances_zero(self): + """--num 0 rejected with error message.""" + result = runner.invoke(app, ["serve", "--num", "0"]) + assert result.exit_code == 1 + assert "--num must be >= 1" in result.output + + def test_serve_num_instances_negative(self): + """Negative --num rejected.""" + result = runner.invoke(app, ["serve", "--num", "-1"]) + assert result.exit_code == 1 + assert "--num must be >= 1" in result.output + + # --- Render mode validation tests --- + + def test_serve_invalid_render_mode(self): + """Invalid render mode rejected with error message.""" + result = runner.invoke(app, ["serve", "--render", "invalid"]) + assert result.exit_code == 1 + assert "Invalid render mode 'invalid'" in result.output + # --- Help text tests --- def test_serve_help(self): """serve --help shows all options.""" result = runner.invoke(app, ["serve", "--help"]) assert result.exit_code == 0 - assert "--host" in result.output - assert "--port" in result.output - assert "--fast" in result.output - assert "--headless" in result.output + assert "--settings" in result.output + assert "--render" in result.output assert "--platform" in result.output + assert "--num" in result.output + assert "--debug" in result.output + assert "--path-balatro" in result.output + assert "--path-lovely" in result.output + assert "--path-love" in result.output + assert "--path-logs" in result.output + assert "--host" in result.output + # Old flags should NOT be present + assert "--fast" not in result.output + assert "--headless" not in result.output + assert "--render-on-api" not in result.output + assert "--audio" not in result.output + assert "--no-shaders" not in result.output + assert "--gamespeed" not in result.output + assert "--fps-cap" not in result.output + assert "--animation-fps" not in result.output + assert "--no-reduced-motion" not in result.output + assert "--pixel-art-smoothing" not in result.output + + def test_serve_settings_help_text(self): + """--settings help shows profile name description.""" + result = runner.invoke(app, ["serve", "--help"]) + assert result.exit_code == 0 + assert ( + "profile name" in result.output.lower() + or "Settings profile" in result.output + ) + + # --- Settings callback validation tests --- + + def test_serve_settings_valid_name(self): + """Valid profile names accepted by --settings.""" + from balatrobot.cli.serve import settings_callback + + assert settings_callback(None) is None + assert settings_callback("fast") == "fast" + assert settings_callback("headless") == "headless" + assert settings_callback("my-profile") == "my-profile" + assert settings_callback("my_profile") == "my_profile" + assert settings_callback("Profile123") == "Profile123" + + def test_serve_settings_rejects_path(self): + """--settings rejects paths with slashes.""" + result = runner.invoke(app, ["serve", "--settings", "/path/to/profile"]) + assert result.exit_code != 0 + + def test_serve_settings_rejects_dotdot(self): + """--settings rejects '..' traversal.""" + result = runner.invoke(app, ["serve", "--settings", "../etc/passwd"]) + assert result.exit_code != 0 + + def test_serve_settings_rejects_empty(self): + """--settings rejects empty-ish names.""" + import typer + + from balatrobot.cli.serve import settings_callback + + with pytest.raises(typer.BadParameter): + settings_callback("") + + def test_serve_settings_rejects_leading_hyphen(self): + """--settings rejects names starting with hyphen.""" + import typer + + from balatrobot.cli.serve import settings_callback + + with pytest.raises(typer.BadParameter): + settings_callback("-bad") # --- Config.from_kwargs tests --- @@ -44,7 +133,7 @@ def test_config_from_kwargs_explicit_overrides_env(self, clean_env, monkeypatch) monkeypatch.setenv("BALATROBOT_HOST", "env-host") - config = Config.from_kwargs(host="cli-host", port=None) + config = Config.from_kwargs(host="cli-host") assert config.host == "cli-host" def test_config_from_kwargs_falls_back_to_env(self, clean_env, monkeypatch): @@ -53,20 +142,17 @@ def test_config_from_kwargs_falls_back_to_env(self, clean_env, monkeypatch): monkeypatch.setenv("BALATROBOT_HOST", "env-host") - config = Config.from_kwargs(host=None, port=9999) + config = Config.from_kwargs(host=None) assert config.host == "env-host" - assert config.port == 9999 def test_config_from_kwargs_env_var_fallback(self, clean_env, monkeypatch): """Env vars used when options not provided.""" from balatrobot.config import Config - monkeypatch.setenv("BALATROBOT_FAST", "1") - monkeypatch.setenv("BALATROBOT_PORT", "8888") + monkeypatch.setenv("BALATROBOT_DEBUG", "1") - config = Config.from_kwargs(fast=None, port=None) - assert config.fast is True - assert config.port == 8888 + config = Config.from_kwargs(debug=None) + assert config.debug is True class TestMainApp: @@ -78,6 +164,8 @@ def test_main_help(self): assert result.exit_code == 0 assert "serve" in result.output assert "api" in result.output + assert "list" in result.output + assert "stop" in result.output def test_no_args_shows_help(self): """Running without args shows help (exit code 2 for multi-command apps).""" diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py new file mode 100644 index 00000000..fd8a497f --- /dev/null +++ b/tests/cli/test_server.py @@ -0,0 +1,263 @@ +"""Tests for balatrobot.cli.serve.Server class.""" + +import json +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from balatrobot.cli.serve import Server +from balatrobot.config import Config +from balatrobot.instance import InstanceDiedError +from balatrobot.pool import BalatroPool +from balatrobot.state import StateFileBusy + + +class TestServerContextManager: + """Tests for Server async context manager lifecycle.""" + + @pytest.mark.asyncio + async def test_enter_writes_state_exit_deletes(self, tmp_path): + """State file exists inside with block, gone after exit.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + async with Server(config, n=1, state_path=state_path) as _server: + assert state_path.exists() + data = json.loads(state_path.read_text()) + assert data["pid"] == os.getpid() + assert len(data["instances"]) == 1 + assert data["instances"][0]["port"] == 14001 + + assert not state_path.exists() + + @pytest.mark.asyncio + async def test_enter_double_start_raises_busy(self, tmp_path): + """StateFileBusy raised if another live state file exists.""" + state_path = tmp_path / "state.json" + + # Write a "live" state file with current PID + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], + } + state_path.write_text(json.dumps(state_data)) + + config = Config(path_logs=str(tmp_path)) + server = Server(config, n=1, state_path=state_path) + + with pytest.raises(StateFileBusy): + await server.__aenter__() + + @pytest.mark.asyncio + async def test_enter_pool_failure_cleans_up(self, tmp_path): + """No state file left if pool.start() fails.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock(side_effect=RuntimeError("start failed")) + mock_inst.stop = AsyncMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + server = Server(config, n=1, state_path=state_path) + with pytest.raises(RuntimeError, match="start failed"): + await server.__aenter__() + + # State file should not exist + assert not state_path.exists() + + @pytest.mark.asyncio + async def test_pool_property(self, tmp_path): + """Server.pool returns the pool after enter.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + async with Server(config, n=1, state_path=state_path) as server: + assert server.pool is not None + assert isinstance(server.pool, BalatroPool) + assert server.pool.is_started is True + + +class TestServerRun: + """Tests for Server.run() supervision loop.""" + + @pytest.mark.asyncio + async def test_run_sigterm_exits_cleanly(self, tmp_path): + """run() returns normally when shutdown event is set.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + async with Server(config, n=1, state_path=state_path) as server: + # Pre-set the shutdown event so run() exits immediately + server._shutdown.set() + await server.run() # Should return without error + + @pytest.mark.asyncio + async def test_run_child_death_raises(self, tmp_path): + """run() raises InstanceDiedError when child dies, state file cleaned up.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + async with Server(config, n=1, state_path=state_path) as server: + # Mock check_alive to raise InstanceDiedError + assert server._pool is not None + server._pool.check_alive = MagicMock( # ty: ignore[invalid-assignment] + side_effect=InstanceDiedError( + port=14001, log_path="/tmp/test-logs/14001.log" + ) + ) + with pytest.raises(InstanceDiedError) as exc_info: + await server.run() + assert exc_info.value.port == 14001 + + # State file should be cleaned up by __aexit__ + assert not state_path.exists() + + @pytest.mark.asyncio + async def test_run_skips_signal_handler_on_windows(self, tmp_path): + """run() does not register signal handlers on Windows.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + patch("balatrobot.cli.serve.sys") as mock_sys, + patch("balatrobot.cli.serve.asyncio.get_running_loop") as mock_get_loop, + ): + mock_sys.platform = "win32" + mock_loop = MagicMock() + mock_get_loop.return_value = mock_loop + + async with Server(config, n=1, state_path=state_path) as server: + server._shutdown.set() + await server.run() + + # add_signal_handler should never be called on Windows + mock_loop.add_signal_handler.assert_not_called() + mock_loop.remove_signal_handler.assert_not_called() + + @pytest.mark.asyncio + async def test_run_registers_sigterm_handler(self, tmp_path): + """run() registers a SIGTERM handler on non-Windows.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + patch("balatrobot.cli.serve.asyncio.get_running_loop") as mock_get_loop, + patch("balatrobot.cli.serve.signal.SIGTERM", object()), + ): + mock_loop = MagicMock() + mock_get_loop.return_value = mock_loop + + async with Server(config, n=1, state_path=state_path) as server: + server._shutdown.set() + await server.run() + + # Verify SIGTERM handler was registered and later removed + mock_loop.add_signal_handler.assert_called_once() + mock_loop.remove_signal_handler.assert_called_once() + call_args = mock_loop.add_signal_handler.call_args + assert call_args[0][1] == server._shutdown.set + + @pytest.mark.asyncio + async def test_sigterm_triggers_clean_shutdown(self, tmp_path): + """SIGTERM sets shutdown event → run() exits → __aexit__ cleans up.""" + state_path = tmp_path / "state.json" + config = Config(path_logs=str(tmp_path)) + + mock_inst = MagicMock() + mock_inst.port = 14001 + mock_inst.log_path = "/tmp/test-logs/14001.log" + mock_inst.start = AsyncMock() + mock_inst.stop = AsyncMock() + mock_inst.check_alive = MagicMock() + + with ( + patch("balatrobot.pool.BalatroInstance", return_value=mock_inst), + patch("balatrobot.state.allocate_ports", return_value=[14001]), + ): + # Verify state file and pool are cleaned up after SIGTERM-like flow + async with Server(config, n=1, state_path=state_path) as server: + assert state_path.exists() + assert server.pool is not None + assert server.pool.is_started + + # Simulate SIGTERM: set the shutdown event + server._shutdown.set() + await server.run() + + # After __aexit__: state file deleted, pool stopped + assert not state_path.exists() + mock_inst.stop.assert_called() diff --git a/tests/cli/test_state.py b/tests/cli/test_state.py new file mode 100644 index 00000000..a4b0771f --- /dev/null +++ b/tests/cli/test_state.py @@ -0,0 +1,387 @@ +"""Tests for balatrobot.state module.""" + +import json +import os +from pathlib import Path + +import pytest + +from balatrobot.state import ( + InstanceNotFoundError, + StateFile, + StateFileBusy, + StateFileError, + StateFileNotFound, + allocate_ports, +) + +# ============================================================================ +# allocate_ports tests +# ============================================================================ + + +class TestAllocatePorts: + """Tests for allocate_ports helper.""" + + def test_allocate_one_port(self): + """Allocates one port.""" + ports = allocate_ports(1) + assert len(ports) == 1 + assert isinstance(ports[0], int) + + def test_allocate_multiple_ports(self): + """Allocates multiple distinct ports.""" + ports = allocate_ports(3) + assert len(ports) == 3 + assert len(set(ports)) == 3 # All unique + + def test_allocate_zero_ports(self): + """Allocates zero ports.""" + ports = allocate_ports(0) + assert ports == [] + + def test_ports_in_valid_range(self): + """Allocated ports are in valid ephemeral range.""" + ports = allocate_ports(5) + for port in ports: + assert 1024 <= port <= 65535 + + +# ============================================================================ +# Exception hierarchy tests +# ============================================================================ + + +class TestExceptions: + """Tests for state exception hierarchy.""" + + def test_state_file_error_base(self): + """StateFileError is the base exception.""" + err = StateFileError("test") + assert isinstance(err, Exception) + assert str(err) == "test" + + def test_state_file_busy(self): + """StateFileBusy is a StateFileError.""" + err = StateFileBusy(path="/tmp/state.json", pid=1234) + assert isinstance(err, StateFileError) + assert err.path == "/tmp/state.json" + assert err.pid == 1234 + + def test_state_file_not_found(self): + """StateFileNotFound is a StateFileError.""" + err = StateFileNotFound(path="/tmp/state.json") + assert isinstance(err, StateFileError) + assert err.path == "/tmp/state.json" + + def test_instance_not_found_error(self): + """InstanceNotFoundError is a StateFileError.""" + err = InstanceNotFoundError(index=5, total=3) + assert isinstance(err, StateFileError) + assert err.index == 5 + assert err.total == 3 + + +# ============================================================================ +# StateFile.read tests +# ============================================================================ + + +class TestStateFileRead: + """Tests for StateFile.read static method.""" + + def test_read_valid_state(self, tmp_path): + """Reads a valid state file.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = StateFile.read(state_path) + assert result is not None + assert result["pid"] == os.getpid() + assert len(result["instances"]) == 2 + + def test_read_missing_file(self, tmp_path): + """Returns None for missing file.""" + result = StateFile.read(tmp_path / "nonexistent.json") + assert result is None + + def test_read_stale_state_auto_deletes(self, tmp_path): + """Auto-deletes state file if PID is no longer alive.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": 999999999, # Non-existent PID + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], + } + state_path.write_text(json.dumps(state_data)) + + result = StateFile.read(state_path) + assert result is None + assert not state_path.exists() + + def test_read_invalid_json(self, tmp_path): + """Returns None for invalid JSON.""" + state_path = tmp_path / "state.json" + state_path.write_text("not json") + + result = StateFile.read(state_path) + assert result is None + + def test_read_default_path(self, tmp_path, monkeypatch): + """Reads from default path when no path given.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = StateFile.read() + assert result is None # No file exists yet + + +# ============================================================================ +# StateFile.resolve tests +# ============================================================================ + + +class TestStateFileResolve: + """Tests for StateFile.resolve static method.""" + + def test_resolve_by_host_port(self, tmp_path, monkeypatch): + """Resolves by explicit host and port.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + info = StateFile.resolve(host="127.0.0.1", port=14002) + assert info.port == 14002 + assert info.log_path == Path("/tmp/logs/s/14002.log") + + def test_resolve_by_index(self, tmp_path, monkeypatch): + """Resolves by index.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + info = StateFile.resolve(index=1) + assert info.port == 14002 + assert info.log_path == Path("/tmp/logs/s/14002.log") + + def test_resolve_no_state_file(self, tmp_path, monkeypatch): + """Raises StateFileNotFound when no state file.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + with pytest.raises(StateFileNotFound): + StateFile.resolve() + + def test_resolve_empty_instances(self, tmp_path, monkeypatch): + """Raises StateFileNotFound when instances list is empty.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + with pytest.raises(StateFileNotFound): + StateFile.resolve() + + def test_resolve_index_out_of_range(self, tmp_path, monkeypatch): + """Raises InstanceNotFoundError for invalid index.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + with pytest.raises(InstanceNotFoundError) as exc_info: + StateFile.resolve(index=5) + assert exc_info.value.index == 5 + assert exc_info.value.total == 1 + + def test_resolve_default_index_zero(self, tmp_path, monkeypatch): + """Default index is 0.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + }, + { + "host": "127.0.0.1", + "port": 14002, + "log_path": "/tmp/logs/s/14002.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + info = StateFile.resolve() + assert info.port == 14001 # index=0 by default + assert info.log_path == Path("/tmp/logs/s/14001.log") + + def test_resolve_host_port_not_in_instances(self, tmp_path, monkeypatch): + """Raises InstanceNotFoundError when host:port not found in instances.""" + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/s/14001.log", + } + ], + } + state_path.write_text(json.dumps(state_data)) + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + with pytest.raises(InstanceNotFoundError): + StateFile.resolve(host="127.0.0.1", port=99999) + + +# ============================================================================ +# StateFile.write / delete tests +# ============================================================================ + + +class TestStateFileWriteDelete: + """Tests for StateFile.write and StateFile.delete static methods.""" + + def test_write_creates_state_file(self, tmp_path): + """write() creates a valid state file.""" + from balatrobot.instance import InstanceInfo + + state_path = tmp_path / "state.json" + instances = [ + InstanceInfo(host="127.0.0.1", port=14001, log_path=Path("/tmp/a.log")), + InstanceInfo(host="127.0.0.1", port=14002, log_path=None), + ] + StateFile.write(state_path, pid=12345, instances=instances) + + assert state_path.exists() + data = json.loads(state_path.read_text()) + assert data["pid"] == 12345 + assert "started_at" in data + assert len(data["instances"]) == 2 + assert data["instances"][0]["host"] == "127.0.0.1" + assert data["instances"][0]["port"] == 14001 + assert data["instances"][0]["log_path"] == "/tmp/a.log" + assert data["instances"][1]["log_path"] is None + + def test_write_atomic(self, tmp_path): + """write() succeeds (smoke test for atomicity).""" + from balatrobot.instance import InstanceInfo + + state_path = tmp_path / "state.json" + instances = [InstanceInfo(host="127.0.0.1", port=14001)] + StateFile.write(state_path, pid=os.getpid(), instances=instances) + assert state_path.exists() + + def test_write_creates_parent_dir(self, tmp_path): + """write() creates parent directories if they don't exist.""" + from balatrobot.instance import InstanceInfo + + state_path = tmp_path / "nested" / "dir" / "state.json" + instances = [InstanceInfo(host="127.0.0.1", port=14001)] + StateFile.write(state_path, pid=os.getpid(), instances=instances) + assert state_path.exists() + + def test_delete_removes_file(self, tmp_path): + """delete() removes an existing state file.""" + from balatrobot.instance import InstanceInfo + + state_path = tmp_path / "state.json" + instances = [InstanceInfo(host="127.0.0.1", port=14001)] + StateFile.write(state_path, pid=os.getpid(), instances=instances) + assert state_path.exists() + + StateFile.delete(state_path) + assert not state_path.exists() + + def test_delete_silent_on_missing(self, tmp_path): + """delete() doesn't raise for non-existent path.""" + state_path = tmp_path / "nonexistent.json" + StateFile.delete(state_path) # Should not raise + assert not state_path.exists() + + +# ============================================================================ +# StateFile path resolution tests +# ============================================================================ + + +class TestStateFilePath: + """Tests for StateFile default path resolution.""" + + def test_default_path_uses_env_var(self, tmp_path, monkeypatch): + """BALATROBOT_STATE_DIR overrides default path.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + from balatrobot.state import default_state_path + + assert default_state_path() == tmp_path / "state.json" diff --git a/tests/cli/test_stop_cmd.py b/tests/cli/test_stop_cmd.py new file mode 100644 index 00000000..a814eb83 --- /dev/null +++ b/tests/cli/test_stop_cmd.py @@ -0,0 +1,146 @@ +"""Tests for balatrobot stop command.""" + +import json +import os +import signal +from unittest.mock import patch + +from typer.testing import CliRunner + +from balatrobot.cli import app + +runner = CliRunner() + + +class TestStopCommand: + """Test balatrobot stop command.""" + + def test_stop_help(self): + """Stop --help shows usage.""" + result = runner.invoke(app, ["stop", "--help"]) + assert result.exit_code == 0 + assert "stop" in result.output.lower() + + def test_stop_no_state_file(self, tmp_path, monkeypatch): + """Stop shows message when no state file exists.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0 + assert "No running instances" in result.output + + def test_stop_dead_pid_in_state_file(self, tmp_path, monkeypatch): + """Dead PID in state file is auto-deleted, shows no instances.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + # Write state file with a PID that definitely doesn't exist + state_path = tmp_path / "state.json" + state_data = { + "pid": 999999999, + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0 + assert "No running instances" in result.output + # State file should have been cleaned up by StateFile.read() + assert not state_path.exists() + + def test_stop_live_pid_sigterm_succeeds(self, tmp_path, monkeypatch): + """Stop sends SIGTERM and reports success when process dies.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + # First os.kill: StateFile.read() alive check (signal 0) → None + # Second os.kill: stop() SIGTERM → ProcessLookupError (already dead) + with patch("balatrobot.cli.stop.os.kill") as mock_kill: + mock_kill.side_effect = [None, ProcessLookupError()] + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 0 + assert f"Server stopped (PID {os.getpid()})" in result.output + assert mock_kill.call_count == 2 + + def test_stop_live_pid_timeout(self, tmp_path, monkeypatch): + """Stop reports error when process won't die within timeout.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + # os.kill always succeeds — process never dies + # time.monotonic jumps forward to skip the 5s wait + with ( + patch("balatrobot.cli.stop.os.kill", return_value=None), + patch("balatrobot.cli.stop.time.monotonic") as mock_time, + patch("balatrobot.cli.stop.time.sleep"), + ): + # deadline = monotonic() + 5.0 = 5.0 + # First poll: monotonic() returns 1.0 (< 5.0, enter loop) + # After sleep, monotonic() returns 100.0 (> 5.0, exit loop → timeout) + mock_time.side_effect = [0.0, 1.0, 100.0] + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 1 + assert "Timed out" in result.output + + def test_stop_permission_denied(self, tmp_path, monkeypatch): + """Stop handles PermissionError from os.kill gracefully.""" + monkeypatch.setenv("BALATROBOT_STATE_DIR", str(tmp_path)) + + state_path = tmp_path / "state.json" + state_data = { + "pid": os.getpid(), + "started_at": "2026-05-28T12:00:00Z", + "instances": [ + { + "host": "127.0.0.1", + "port": 14001, + "log_path": "/tmp/logs/14001.log", + }, + ], + } + state_path.write_text(json.dumps(state_data)) + + def kill_permission_denied(pid, sig): + """Allow signal-0 alive checks, raise on SIGTERM.""" + if sig == signal.SIGTERM: + raise PermissionError("Not allowed") + return None + + with patch("balatrobot.cli.stop.os.kill", side_effect=kill_permission_denied): + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 1 + assert "Permission denied" in result.output diff --git a/tests/conftest.py b/tests/conftest.py index 29d39f97..bbbf82ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1 @@ """Root test configuration.""" - - -def pytest_configure(config): - """Register custom markers.""" - config.addinivalue_line("markers", "integration: marks tests as integration tests") diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json index 44ceffae..25b843d0 100644 --- a/tests/fixtures/fixtures.json +++ b/tests/fixtures/fixtures.json @@ -9,15 +9,15 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } ] }, "gamestate": { - "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE": [ + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white": [ { "method": "menu", "params": {} @@ -25,13 +25,13 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } ], - "state-BLIND_SELECT--deck-BLUE--stake-RED": [ + "state-BLIND_SELECT--deck-b_blue--stake-stake_red": [ { "method": "menu", "params": {} @@ -39,8 +39,8 @@ { "method": "start", "params": { - "deck": "BLUE", - "stake": "RED", + "deck": "b_blue", + "stake": "stake_red", "seed": "TEST123" } } @@ -53,8 +53,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -86,8 +86,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -120,8 +120,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -156,8 +156,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "QKNXF682" } }, @@ -199,8 +199,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -219,8 +219,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -235,14 +235,28 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } ] }, "set": { + "state-BLIND_SELECT": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + } + ], "state-SELECTING_HAND": [ { "method": "menu", @@ -251,8 +265,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -269,8 +283,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -307,8 +321,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -323,8 +337,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -339,8 +353,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -353,8 +367,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -371,8 +385,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -395,8 +409,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -409,8 +423,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -427,8 +441,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -451,8 +465,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -465,8 +479,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -483,8 +497,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -509,8 +523,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -533,8 +547,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -557,6 +571,38 @@ "chips": 1000000 } } + ], + "state-SELECTING_HAND--blinds.boss.key-bl_final_bell": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + }, + { + "method": "set", + "params": { + "boss": "bl_final_bell" + } + }, + { + "method": "skip", + "params": {} + }, + { + "method": "skip", + "params": {} + }, + { + "method": "select", + "params": {} + } ] }, "discard": { @@ -568,8 +614,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -582,8 +628,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -600,8 +646,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -615,6 +661,38 @@ "discards": 0 } } + ], + "state-SELECTING_HAND--blinds.boss.key-bl_final_bell": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + }, + { + "method": "set", + "params": { + "boss": "bl_final_bell" + } + }, + { + "method": "skip", + "params": {} + }, + { + "method": "skip", + "params": {} + }, + { + "method": "select", + "params": {} + } ] }, "cash_out": { @@ -626,8 +704,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -640,8 +718,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -674,8 +752,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -688,8 +766,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -726,8 +804,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -740,8 +818,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -776,8 +854,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -820,8 +898,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -834,8 +912,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -870,8 +948,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -906,8 +984,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -942,8 +1020,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -978,8 +1056,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1020,8 +1098,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1060,8 +1138,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1108,8 +1186,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1187,8 +1265,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1252,8 +1330,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1294,8 +1372,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1346,10 +1424,8 @@ "skip": true } } - ] - }, - "pack": { - "state-SHOP": [ + ], + "state-SHOP--cards.count-2--packs[1].label-Arcana+Pack": [ { "method": "menu", "params": {} @@ -1357,9 +1433,9 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "TEST123" + "deck": "b_abandoned", + "stake": "stake_white", + "seed": "ISSUE197" } }, { @@ -1367,268 +1443,297 @@ "params": {} }, { - "method": "set", + "method": "add", "params": { - "chips": 1000, - "money": 1000 + "key": "c_hanged_man" } }, { - "method": "play", + "method": "use", "params": { + "consumable": 0, "cards": [ - 0 + 0, + 1 ] } }, { - "method": "cash_out", - "params": {} - } - ], - "state-SHOP--packs.count-0": [ - { - "method": "menu", - "params": {} - }, - { - "method": "start", + "method": "add", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "TEST123" + "key": "c_hanged_man" } }, { - "method": "select", - "params": {} + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } }, { - "method": "set", + "method": "add", "params": { - "chips": 1000, - "money": 1000 + "key": "c_hanged_man" } }, { - "method": "play", + "method": "use", "params": { + "consumable": 0, "cards": [ - 0 + 0, + 1 ] } }, { - "method": "cash_out", - "params": {} - }, - { - "method": "buy", + "method": "add", "params": { - "pack": 0 + "key": "c_hanged_man" } }, { - "method": "pack", + "method": "use", "params": { - "skip": true + "consumable": 0, + "cards": [ + 0, + 1 + ] } }, { - "method": "buy", + "method": "add", "params": { - "pack": 0 + "key": "c_hanged_man" } }, { - "method": "pack", + "method": "use", "params": { - "skip": true + "consumable": 0, + "cards": [ + 0, + 1 + ] } - } - ], - "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-5": [ - { - "method": "menu", - "params": {} }, { - "method": "start", + "method": "add", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "TEST123" + "key": "c_hanged_man" } }, { - "method": "select", - "params": {} + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } }, { - "method": "set", + "method": "add", "params": { - "chips": 1000, - "money": 1000 + "key": "c_hanged_man" } }, { - "method": "play", + "method": "use", "params": { + "consumable": 0, "cards": [ - 0 + 0, + 1 ] } }, { - "method": "cash_out", - "params": {} - }, - { - "method": "buy", + "method": "add", "params": { - "card": 0 + "key": "c_hanged_man" } }, { - "method": "reroll", - "params": {} - }, - { - "method": "buy", + "method": "use", "params": { - "card": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } }, { - "method": "reroll", - "params": {} + "method": "add", + "params": { + "key": "c_hanged_man" + } }, { - "method": "buy", + "method": "use", "params": { - "card": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } }, { - "method": "buy", + "method": "add", "params": { - "card": 0 + "key": "c_hanged_man" } }, { - "method": "reroll", - "params": {} + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } }, { - "method": "buy", + "method": "add", "params": { - "card": 0 + "key": "c_hanged_man" } }, { - "method": "buy", + "method": "use", "params": { - "pack": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } - } - ], - "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-4": [ - { - "method": "menu", - "params": {} }, { - "method": "start", + "method": "add", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "TEST123" + "key": "c_hanged_man" } }, { - "method": "select", - "params": {} + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } }, { - "method": "set", + "method": "add", "params": { - "chips": 1000, - "money": 1000 + "key": "c_hanged_man" } }, { - "method": "play", + "method": "use", "params": { + "consumable": 0, "cards": [ - 0 + 0, + 1 ] } }, { - "method": "cash_out", - "params": {} + "method": "add", + "params": { + "key": "c_hanged_man" + } }, { - "method": "buy", + "method": "use", "params": { - "card": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } }, { - "method": "reroll", - "params": {} + "method": "add", + "params": { + "key": "c_hanged_man" + } }, { - "method": "buy", + "method": "use", "params": { - "card": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } }, { - "method": "reroll", - "params": {} + "method": "add", + "params": { + "key": "c_hanged_man" + } }, { - "method": "buy", + "method": "use", "params": { - "card": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } }, { - "method": "buy", + "method": "add", "params": { - "card": 0 + "key": "c_hanged_man" } }, { - "method": "buy", + "method": "use", "params": { - "pack": 0 + "consumable": 0, + "cards": [ + 0, + 1 + ] } - } - ], - "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_heirophant": [ - { - "method": "menu", - "params": {} }, { - "method": "start", + "method": "add", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "QKNXF682" + "key": "c_hanged_man" } }, { - "method": "select", - "params": {} + "method": "use", + "params": { + "consumable": 0, + "cards": [ + 0, + 1 + ] + } }, { "method": "set", "params": { - "chips": 1000, - "money": 1000 + "chips": 100000 } }, { @@ -1643,14 +1748,897 @@ "method": "cash_out", "params": {} }, + { + "method": "set", + "params": { + "money": 100 + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_arcana_normal_1" + } + } + ], + "seed-S001250--state-SHOP--pack.cards[0].set-SPECTRAL": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "S001250" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 100000, + "money": 999 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0, + 1, + 2, + 3, + 4 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, + { + "method": "buy", + "params": { + "pack": 1 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + } + ] + }, + "pack": { + "state-SHOP": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + } + ], + "state-SHOP--packs.count-0": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + } + ], + "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-5": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "reroll", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "reroll", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "reroll", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + } + ], + "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-4": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "TEST123" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "reroll", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "reroll", + "params": {} + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "buy", + "params": { + "card": 0 + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + } + ], + "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_heirophant": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "QKNXF682" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 1 + } + } + ], + "state-SMODS_BOOSTER_OPENED--pack.cards[1].key-c_aura": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "3Q1KBVT3" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 1 + } + } + ], + "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_ankh--jokers.count-1": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "3Q1KBVT3" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "card": 0 + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + } + ], + "state-SMODS_BOOSTER_OPENED--pack.key-p_celestial_mega_1": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "VPV32ZTY" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 1 + } + } + ], + "seed-VEBROR8--state-SMODS_BOOSTER_OPENED--pack.key-p_arcana_mega_1": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "VEBROR8" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 100 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_arcana_mega_1" + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + } + ], + "seed-7IDNRIV--state-SMODS_BOOSTER_OPENED--pack.cards[2].key-c_black_hole": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "7IDNRIV" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000, + "money": 100 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "buy", + "params": { + "pack": 0 + } + }, + { + "method": "pack", + "params": { + "skip": true + } + }, + { + "method": "add", + "params": { + "key": "p_celestial_mega_1" + } + }, { "method": "buy", "params": { - "pack": 1 + "pack": 0 } } ], - "state-SMODS_BOOSTER_OPENED--pack.cards[1].key-c_aura": [ + "seed-TAGTEST2--state-SMODS_BOOSTER_OPENED--blinds.small.tag.key-tag_charm": [ { "method": "menu", "params": {} @@ -1658,9 +2646,31 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "3Q1KBVT3" + "deck": "b_red", + "stake": "stake_white", + "seed": "TAGTEST2" + } + }, + { + "method": "skip", + "params": {} + }, + { + "method": "gamestate", + "params": {} + } + ], + "seed-S001250--state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_black_hole": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "S001250" } }, { @@ -1670,15 +2680,19 @@ { "method": "set", "params": { - "chips": 1000, - "money": 1000 + "chips": 100000, + "money": 999 } }, { "method": "play", "params": { "cards": [ - 0 + 0, + 1, + 2, + 3, + 4 ] } }, @@ -1689,148 +2703,133 @@ { "method": "buy", "params": { - "pack": 1 + "pack": 0 } - } - ], - "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_ankh--jokers.count-1": [ - { - "method": "menu", - "params": {} }, { - "method": "start", + "method": "pack", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "3Q1KBVT3" + "skip": true } }, { - "method": "select", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { - "method": "set", + "method": "buy", "params": { - "chips": 1000, - "money": 1000 + "pack": 1 } }, { - "method": "play", + "method": "pack", "params": { - "cards": [ - 0 - ] + "skip": true } }, { - "method": "cash_out", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } }, { "method": "pack", "params": { - "card": 0 + "skip": true } }, { - "method": "buy", + "method": "add", "params": { - "pack": 0 + "key": "p_celestial_normal_1" } - } - ], - "state-SMODS_BOOSTER_OPENED--pack.key-p_celestial_mega_1": [ + }, { - "method": "menu", - "params": {} + "method": "buy", + "params": { + "pack": 1 + } }, { - "method": "start", + "method": "pack", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "VPV32ZTY" + "skip": true } }, { - "method": "select", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { - "method": "set", + "method": "buy", "params": { - "chips": 1000, - "money": 1000 + "pack": 1 } }, { - "method": "play", + "method": "pack", "params": { - "cards": [ - 0 - ] + "skip": true } }, { - "method": "cash_out", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { "method": "buy", "params": { "pack": 1 } - } - ], - "seed-VEBROR8--state-SMODS_BOOSTER_OPENED--pack.key-p_arcana_mega_1": [ - { - "method": "menu", - "params": {} }, { - "method": "start", + "method": "pack", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "VEBROR8" + "skip": true } }, { - "method": "select", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { - "method": "set", + "method": "buy", "params": { - "chips": 1000, - "money": 100 + "pack": 1 } }, { - "method": "play", + "method": "pack", "params": { - "cards": [ - 0 - ] + "skip": true } }, { - "method": "cash_out", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } }, { @@ -1839,10 +2838,16 @@ "skip": true } }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } }, { @@ -1854,56 +2859,49 @@ { "method": "add", "params": { - "key": "p_arcana_mega_1" + "key": "p_celestial_normal_1" } }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } - } - ], - "seed-7IDNRIV--state-SMODS_BOOSTER_OPENED--pack.cards[2].key-c_black_hole": [ - { - "method": "menu", - "params": {} }, { - "method": "start", + "method": "pack", "params": { - "deck": "RED", - "stake": "WHITE", - "seed": "7IDNRIV" + "skip": true } }, { - "method": "select", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { - "method": "set", + "method": "buy", "params": { - "chips": 1000, - "money": 100 + "pack": 1 } }, { - "method": "play", + "method": "pack", "params": { - "cards": [ - 0 - ] + "skip": true } }, { - "method": "cash_out", - "params": {} + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } }, { @@ -1912,10 +2910,16 @@ "skip": true } }, + { + "method": "add", + "params": { + "key": "p_celestial_normal_1" + } + }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } }, { @@ -1927,13 +2931,13 @@ { "method": "add", "params": { - "key": "p_celestial_mega_1" + "key": "p_celestial_normal_1" } }, { "method": "buy", "params": { - "pack": 0 + "pack": 1 } } ] @@ -1947,8 +2951,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -1961,8 +2965,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -1993,8 +2997,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2011,8 +3015,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2060,8 +3064,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2108,6 +3112,80 @@ "method": "select", "params": {} } + ], + "state-SHOP--jokers.count-2--jokers.cards[0].key-j_invisible": [ + { + "method": "menu", + "params": {} + }, + { + "method": "start", + "params": { + "deck": "b_red", + "stake": "stake_white", + "seed": "INVIS3" + } + }, + { + "method": "select", + "params": {} + }, + { + "method": "add", + "params": { + "key": "j_invisible" + } + }, + { + "method": "set", + "params": { + "chips": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "next_round", + "params": {} + }, + { + "method": "select", + "params": {} + }, + { + "method": "set", + "params": { + "chips": 1000 + } + }, + { + "method": "play", + "params": { + "cards": [ + 0 + ] + } + }, + { + "method": "cash_out", + "params": {} + }, + { + "method": "add", + "params": { + "key": "j_greedy_joker" + } + } ] }, "add": { @@ -2119,8 +3197,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -2133,8 +3211,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2151,8 +3229,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2187,8 +3265,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2256,8 +3334,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -2270,8 +3348,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2303,8 +3381,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2333,8 +3411,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2357,8 +3435,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2399,8 +3477,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2423,8 +3501,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2471,8 +3549,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2532,8 +3610,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2556,8 +3634,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2599,8 +3677,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2644,8 +3722,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } @@ -2658,8 +3736,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2676,8 +3754,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2757,8 +3835,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } }, @@ -2820,8 +3898,8 @@ { "method": "start", "params": { - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "seed": "TEST123" } } diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py index 773722ff..78d05679 100644 --- a/tests/fixtures/generate.py +++ b/tests/fixtures/generate.py @@ -8,9 +8,9 @@ import httpx from tqdm import tqdm +from balatrobot.state import InstanceNotFoundError, StateFile, StateFileNotFound + FIXTURES_DIR = Path(__file__).parent -HOST = "127.0.0.1" -PORT = 12346 # JSON-RPC 2.0 request ID counter _request_id: int = 0 @@ -119,7 +119,18 @@ def generate_fixture(client: httpx.Client, spec: FixtureSpec, pbar: tqdm) -> boo def main() -> int: print("BalatroBot Fixture Generator") - print(f"Connecting to {HOST}:{PORT}\n") + + try: + info = StateFile.resolve() + except (StateFileNotFound, InstanceNotFoundError) as e: + print(f"Error: {e}") + print( + "Make sure Balatro is running (balatrobot serve --settings turbo --debug --render headfull)" + ) + return 1 + + host, port = info.host, info.port + print(f"Connecting to {host}:{port}\n") json_data = load_fixtures_json() fixtures = aggregate_fixtures(json_data) @@ -127,7 +138,7 @@ def main() -> int: try: with httpx.Client( - base_url=f"http://{HOST}:{PORT}", + base_url=f"http://{host}:{port}", timeout=httpx.Timeout(60.0, read=10.0), ) as client: success = 0 @@ -153,11 +164,11 @@ def main() -> int: return 1 if failed > 0 else 0 except httpx.ConnectError: - print(f"Error: Could not connect to Balatro at {HOST}:{PORT}") + print(f"Error: Could not connect to Balatro at {host}:{port}") print("Make sure Balatro is running with BalatroBot mod loaded") return 1 except httpx.TimeoutException: - print(f"Error: Connection timeout to Balatro at {HOST}:{PORT}") + print(f"Error: Connection timeout to Balatro at {host}:{port}") return 1 except Exception as e: print(f"Error: {e}") diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py index b333933d..bbe7c7f0 100644 --- a/tests/lua/conftest.py +++ b/tests/lua/conftest.py @@ -13,7 +13,7 @@ import pytest from balatrobot.config import Config -from balatrobot.manager import BalatroInstance +from balatrobot.instance import BalatroInstance, InstanceInfo # ============================================================================ # Constants @@ -118,65 +118,55 @@ async def stop_all(): def pytest_collection_modifyitems(items): - """Mark all tests in this directory as integration tests.""" - from pathlib import Path - - current_dir = Path(__file__).parent - - for item in items: - # Check if the test file is within the current directory - if current_dir in Path(item.path).parents: - item.add_marker(pytest.mark.integration) - - -@pytest.fixture(scope="session") -def host() -> str: - """Return the default Balatro server host.""" - return HOST + """No-op placeholder. Kept for pytest hook consistency.""" @pytest.fixture(scope="session") -def port(worker_id) -> int: - """Get assigned port for this worker from env var.""" +def instance(worker_id) -> InstanceInfo: + """Return InstanceInfo for this worker's assigned instance.""" ports_str = os.environ.get("BALATROBOT_PORTS", "12346") ports = [int(p) for p in ports_str.split(",")] if worker_id == "master": - return ports[0] + port = ports[0] + else: + worker_num = int(worker_id.replace("gw", "")) + port = ports[worker_num] - worker_num = int(worker_id.replace("gw", "")) - return ports[worker_num] + return InstanceInfo(host=HOST, port=port) @pytest.fixture(scope="session") -async def balatro_server(port: int, worker_id) -> AsyncGenerator[None, None]: +async def balatro_server(instance: InstanceInfo) -> AsyncGenerator[None, None]: """Wait for pre-started Balatro instance to be healthy.""" timeout = 10.0 elapsed = 0.0 while elapsed < timeout: - if _check_health(HOST, port): - print(f"[{worker_id}] Connected to Balatro on port {port}") + if _check_health(instance.host, instance.port): + print(f"[worker] Connected to Balatro on port {instance.port}") yield None return await asyncio.sleep(0.5) elapsed += 0.5 - pytest.fail(f"Balatro instance on port {port} not responding") + pytest.fail(f"Balatro instance on port {instance.port} not responding") @pytest.fixture -def client(host: str, port: int, balatro_server) -> Generator[httpx.Client, None, None]: +def client( + instance: InstanceInfo, balatro_server +) -> Generator[httpx.Client, None, None]: """Create an HTTP client connected to Balatro game instance. Args: - host: The hostname or IP address of the Balatro game server. - port: The port number the Balatro game server is listening on. + instance: The InstanceInfo for the assigned instance. + balatro_server: Ensures the server is healthy. Yields: An httpx.Client for communicating with the game. """ with httpx.Client( - base_url=f"http://{host}:{port}", + base_url=instance.url, timeout=httpx.Timeout(CONNECTION_TIMEOUT, read=REQUEST_TIMEOUT), ) as http_client: yield http_client diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py index b3fb9f23..d80d3359 100644 --- a/tests/lua/core/test_server.py +++ b/tests/lua/core/test_server.py @@ -18,22 +18,28 @@ import httpx import pytest +from balatrobot.instance import InstanceInfo + class TestHTTPServerInit: """Tests for HTTP server initialization and port binding.""" - def test_server_binds_to_configured_port(self, port: int, balatro_server) -> None: + def test_server_binds_to_configured_port( + self, instance: InstanceInfo, balatro_server + ) -> None: """Test that server is listening on the expected port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(2) - sock.connect(("127.0.0.1", port)) - assert sock.fileno() != -1, f"Should connect to port {port}" + sock.connect(("127.0.0.1", instance.port)) + assert sock.fileno() != -1, f"Should connect to port {instance.port}" - def test_port_is_exclusively_bound(self, port: int, balatro_server) -> None: + def test_port_is_exclusively_bound( + self, instance: InstanceInfo, balatro_server + ) -> None: """Test that server exclusively binds the port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with pytest.raises(OSError) as exc_info: - sock.bind(("127.0.0.1", port)) + sock.bind(("127.0.0.1", instance.port)) assert exc_info.value.errno == errno.EADDRINUSE def test_server_responds_to_http(self, client: httpx.Client) -> None: @@ -500,7 +506,7 @@ class TestHTTPServerConcurrency: """Tests for concurrent request handling.""" def test_concurrent_requests_do_not_crash( - self, instance, balatro_server, client: httpx.Client + self, instance: InstanceInfo, balatro_server, client: httpx.Client ) -> None: """Two concurrent requests must not crash the server (#193).""" barrier = threading.Barrier(2) diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py index fe11a58b..85c7fd84 100644 --- a/tests/lua/endpoints/test_add.py +++ b/tests/lua/endpoints/test_add.py @@ -155,7 +155,7 @@ def test_invalid_key_unknown_format(self, client: httpx.Client) -> None: assert_error_response( api(client, "add", {"key": "x_unknown"}), "BAD_REQUEST", - "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), or playing card (SUIT_RANK)", + "Invalid card key format. Expected: joker (j_*), consumable (c_*), voucher (v_*), pack (p_*), or playing card (SUIT_RANK)", ) def test_invalid_key_known_format(self, client: httpx.Client) -> None: @@ -265,7 +265,7 @@ def test_add_pack_shop_full(self, client: httpx.Client) -> None: class TestAddEndpointSeal: """Test seal parameter for add endpoint.""" - @pytest.mark.parametrize("seal", ["RED", "BLUE", "GOLD", "PURPLE"]) + @pytest.mark.parametrize("seal", ["Red", "Blue", "Gold", "Purple"]) def test_add_playing_card_with_seal(self, client: httpx.Client, seal: str) -> None: """Test adding a playing card with various seals.""" gamestate = load_fixture( @@ -294,7 +294,7 @@ def test_add_playing_card_invalid_seal(self, client: httpx.Client) -> None: assert_error_response( response, "BAD_REQUEST", - "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE", + "Invalid seal value. Expected a Seal key from G.P_SEALS (e.g. Red, Blue)", ) @pytest.mark.parametrize( @@ -310,7 +310,7 @@ def test_add_non_playing_card_with_seal_fails( "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0--packs.count-0", ) assert gamestate["state"] == "SHOP" - response = api(client, "add", {"key": key, "seal": "RED"}) + response = api(client, "add", {"key": key, "seal": "Red"}) assert_error_response( response, "BAD_REQUEST", @@ -321,7 +321,9 @@ def test_add_non_playing_card_with_seal_fails( class TestAddEndpointEdition: """Test edition parameter for add endpoint.""" - @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) + @pytest.mark.parametrize( + "edition", ["e_holo", "e_foil", "e_polychrome", "e_negative"] + ) def test_add_joker_with_edition(self, client: httpx.Client, edition: str) -> None: """Test adding a joker with various editions.""" gamestate = load_fixture( @@ -337,7 +339,9 @@ def test_add_joker_with_edition(self, client: httpx.Client, edition: str) -> Non assert after["jokers"]["cards"][0]["key"] == "j_joker" assert after["jokers"]["cards"][0]["modifier"]["edition"] == edition - @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"]) + @pytest.mark.parametrize( + "edition", ["e_holo", "e_foil", "e_polychrome", "e_negative"] + ) def test_add_playing_card_with_edition( self, client: httpx.Client, edition: str ) -> None: @@ -356,7 +360,7 @@ def test_add_playing_card_with_edition( assert after["hand"]["cards"][8]["modifier"]["edition"] == edition def test_add_consumable_with_negative_edition(self, client: httpx.Client) -> None: - """Test adding a consumable with NEGATIVE edition (only valid edition for consumables).""" + """Test adding a consumable with e_negative edition (only valid edition for consumables).""" gamestate = load_fixture( client, "add", @@ -364,17 +368,17 @@ def test_add_consumable_with_negative_edition(self, client: httpx.Client) -> Non ) assert gamestate["state"] == "SHOP" assert gamestate["consumables"]["count"] == 0 - response = api(client, "add", {"key": "c_fool", "edition": "NEGATIVE"}) + response = api(client, "add", {"key": "c_fool", "edition": "e_negative"}) after = assert_gamestate_response(response) assert after["consumables"]["count"] == 1 assert after["consumables"]["cards"][0]["key"] == "c_fool" - assert after["consumables"]["cards"][0]["modifier"]["edition"] == "NEGATIVE" + assert after["consumables"]["cards"][0]["modifier"]["edition"] == "e_negative" - @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME"]) + @pytest.mark.parametrize("edition", ["e_holo", "e_foil", "e_polychrome"]) def test_add_consumable_with_non_negative_edition_fails( self, client: httpx.Client, edition: str ) -> None: - """Test that adding a consumable with HOLO | FOIL | POLYCHROME edition fails.""" + """Test that adding a consumable with e_holo | e_foil | e_polychrome edition fails.""" gamestate = load_fixture( client, "add", @@ -386,7 +390,7 @@ def test_add_consumable_with_non_negative_edition_fails( assert_error_response( response, "BAD_REQUEST", - "Consumables can only have NEGATIVE edition", + "Consumables can only have e_negative edition", ) def test_add_voucher_with_edition_fails(self, client: httpx.Client) -> None: @@ -398,7 +402,7 @@ def test_add_voucher_with_edition_fails(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "SHOP" assert gamestate["vouchers"]["count"] == 0 - response = api(client, "add", {"key": "v_overstock_norm", "edition": "FOIL"}) + response = api(client, "add", {"key": "v_overstock_norm", "edition": "e_foil"}) assert_error_response( response, "BAD_REQUEST", "Edition cannot be applied to vouchers" ) @@ -411,7 +415,7 @@ def test_add_pack_with_edition_fails(self, client: httpx.Client) -> None: "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0--packs.count-0", ) assert gamestate["state"] == "SHOP" - response = api(client, "add", {"key": "p_arcana_normal_1", "edition": "FOIL"}) + response = api(client, "add", {"key": "p_arcana_normal_1", "edition": "e_foil"}) assert_error_response( response, "BAD_REQUEST", "Edition cannot be applied to packs" ) @@ -429,7 +433,7 @@ def test_add_playing_card_invalid_edition(self, client: httpx.Client) -> None: assert_error_response( response, "BAD_REQUEST", - "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE", + "Expected an e_* edition key (e.g. e_foil, e_holo)", ) @@ -438,7 +442,16 @@ class TestAddEndpointEnhancement: @pytest.mark.parametrize( "enhancement", - ["BONUS", "MULT", "WILD", "GLASS", "STEEL", "STONE", "GOLD", "LUCKY"], + [ + "m_bonus", + "m_mult", + "m_wild", + "m_glass", + "m_steel", + "m_stone", + "m_gold", + "m_lucky", + ], ) def test_add_playing_card_with_enhancement( self, client: httpx.Client, enhancement: str @@ -470,7 +483,7 @@ def test_add_playing_card_invalid_enhancement(self, client: httpx.Client) -> Non assert_error_response( response, "BAD_REQUEST", - "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY", + "Expected an m_* enhancement key (e.g. m_bonus, m_mult)", ) @pytest.mark.parametrize( @@ -486,7 +499,7 @@ def test_add_non_playing_card_with_enhancement_fails( "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0--packs.count-0", ) assert gamestate["state"] == "SHOP" - response = api(client, "add", {"key": key, "enhancement": "BONUS"}) + response = api(client, "add", {"key": key, "enhancement": "m_bonus"}) assert_error_response( response, "BAD_REQUEST", diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py index 5aaa6081..80e7cbd7 100644 --- a/tests/lua/endpoints/test_buy.py +++ b/tests/lua/endpoints/test_buy.py @@ -46,7 +46,7 @@ def test_buy_no_card_in_shop_area(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 0}), "BAD_REQUEST", - "No jokers/consumables/cards in the shop. Reroll to restock the shop", + "No jokers/consumables/cards in the shop. Use `reroll` to restock the shop.", ) def test_buy_invalid_card_index(self, client: httpx.Client) -> None: @@ -110,7 +110,7 @@ def test_buy_joker_slots_full(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 0}), "BAD_REQUEST", - "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5", + "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5. Sell a joker using `sell` to free a slot.", ) def test_buy_consumable_slots_full(self, client: httpx.Client) -> None: @@ -126,7 +126,7 @@ def test_buy_consumable_slots_full(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"card": 1}), "BAD_REQUEST", - "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2", + "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2. Use `use` to activate a consumable or `sell` to remove one.", ) def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None: @@ -137,7 +137,7 @@ def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"voucher": 0}), "BAD_REQUEST", - "No vouchers to redeem. Defeat boss blind to restock", + "No vouchers to redeem. Defeat boss blind to restock.", ) def test_buy_packs_slot_empty(self, client: httpx.Client) -> None: @@ -148,7 +148,7 @@ def test_buy_packs_slot_empty(self, client: httpx.Client) -> None: assert_error_response( api(client, "buy", {"pack": 0}), "BAD_REQUEST", - "No packs to open", + "No packs to open. Use `next_round` to advance to the next blind and restock the shop.", ) def test_buy_joker_success(self, client: httpx.Client) -> None: @@ -196,6 +196,46 @@ def test_buy_packs_success(self, client: httpx.Client) -> None: assert gamestate["pack"] is not None assert len(gamestate["pack"]["cards"]) > 0 + def test_buy_pack_thin_deck(self, client: httpx.Client) -> None: + """Regression test for #198: buying an Arcana/Spectral pack with + a thin deck (< hand_limit) must not hang. + """ + gamestate = load_fixture( + client, "buy", "state-SHOP--cards.count-2--packs[1].label-Arcana+Pack" + ) + assert gamestate["state"] == "SHOP" + assert gamestate["cards"]["count"] < 8 + + response = api(client, "buy", {"pack": 1}, timeout=10.0) + assert_gamestate_response(response) + + def test_buy_celestial_pack_with_black_hole(self, client: httpx.Client) -> None: + """Regression test for #199: buying a Celestial pack containing Black Hole + (Spectral card) as the first card must not hang. + + Black Hole appears in Celestial packs via the soul mechanism (0.3% chance). + The bug: buy.lua checks first card's ability.set to decide if hand cards + are needed. Black Hole has set=Spectral -> needs_hand=true. But Celestial + packs don't deal hand cards -> endpoint hangs forever. + """ + gamestate = load_fixture( + client, "buy", "seed-S001250--state-SHOP--pack.cards[0].set-SPECTRAL" + ) + assert gamestate["state"] == "SHOP" + + # Find the Celestial pack + celestial_idx = None + for i, pack in enumerate(gamestate["packs"]["cards"]): + if "celestial" in pack["key"].lower(): + celestial_idx = i + break + assert celestial_idx is not None, "No Celestial pack found in shop" + + response = api(client, "buy", {"pack": celestial_idx}, timeout=10.0) + gamestate = assert_gamestate_response(response) + assert gamestate["pack"] is not None + assert len(gamestate["pack"]["cards"]) > 0 + def test_buy_with_credit_card_joker(self, client: httpx.Client) -> None: """Test buying when player has Credit Card joker (can go negative).""" # Get to shop state with $0 diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py index 422deae2..1b199507 100644 --- a/tests/lua/endpoints/test_discard.py +++ b/tests/lua/endpoints/test_discard.py @@ -96,6 +96,34 @@ def test_invalid_cards_type(self, client: httpx.Client): "Field 'cards' must be an array", ) + def test_cerulean_bell_forced_card_not_included_in_discard( + self, client: httpx.Client + ) -> None: + """Discard a single non-forced card; the forced card must NOT be silently included.""" + + prev_gs = load_fixture( + client, "discard", "state-SELECTING_HAND--blinds.boss.key-bl_final_bell" + ) + assert prev_gs["blinds"]["boss"]["key"] == "bl_final_bell" + + # Find the forced card (highlighted by The Bell) + h_idx, h_card = None, None + for i, c in enumerate(prev_gs["hand"]["cards"]): + if isinstance(c["state"], dict) and c["state"]["highlight"]: + h_idx = i + h_card = c + break + assert h_card is not None, "The Bell should force exactly one card" + assert h_idx is not None, "The Bell should force exactly one card" + + # Select another card to discard, this should raise an error in the API. + response = api(client, "discard", {"cards": [1 if h_idx == 0 else 0]}) + assert_error_response( + response, + "BAD_REQUEST", + "forced-selected by the boss blind", + ) + class TestDiscardEndpointStateRequirements: """Test discard endpoint state requirements.""" diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py index e35af2ba..4a3136c4 100644 --- a/tests/lua/endpoints/test_gamestate.py +++ b/tests/lua/endpoints/test_gamestate.py @@ -3,6 +3,7 @@ import re import httpx +import pytest from tests.lua.conftest import api, assert_gamestate_response, load_fixture @@ -18,19 +19,19 @@ def test_gamestate_from_MENU(self, client: httpx.Client) -> None: def test_gamestate_from_BLIND_SELECT(self, client: httpx.Client) -> None: """Test that gamestate from BLIND_SELECT state is valid.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["round_num"] == 0 - assert gamestate["deck"] == "RED" - assert gamestate["stake"] == "WHITE" + assert gamestate["deck"] == "b_red" + assert gamestate["stake"] == "stake_white" response = api(client, "gamestate", {}) assert_gamestate_response( response, state="BLIND_SELECT", round_num=0, - deck="RED", - stake="WHITE", + deck="b_red", + stake="stake_white", ) @@ -38,47 +39,47 @@ class TestGamestateTopLevel: """Test gamestate endpoint with top-level fields.""" def test_deck_extraction(self, client: httpx.Client) -> None: - """Test deck field matches started deck (e.g., "BLUE").""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + """Test deck field matches started deck (e.g., "b_blue").""" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) - assert gamestate["deck"] == "BLUE" + assert gamestate["deck"] == "b_blue" def test_stake_extraction(self, client: httpx.Client) -> None: - """Test stake field matches started stake (e.g., "RED").""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + """Test stake field matches started stake (e.g., "stake_red").""" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) - assert gamestate["stake"] == "RED" + assert gamestate["stake"] == "stake_red" def test_seed_extraction(self, client: httpx.Client) -> None: """Test seed field matches the seed used in `start`.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["seed"] == "TEST123" def test_money_extraction(self, client: httpx.Client) -> None: """Test money field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"money": 42}) assert response["result"]["seed"] == "TEST123" def test_ante_num_extractions(self, client: httpx.Client) -> None: """Test ante_num field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"ante": 5}) assert response["result"]["ante_num"] == 5 def test_round_num_extractions(self, client: httpx.Client) -> None: """Test round_num field after using `set` to modify it.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" load_fixture(client, "gamestate", fixture_name) response = api(client, "set", {"round": 5}) assert response["result"]["round_num"] == 5 def test_won_false_extraction(self, client: httpx.Client) -> None: """Test won field after defeating ante 8 boss.""" - fixture_name = "state-BLIND_SELECT--deck-BLUE--stake-RED" + fixture_name = "state-BLIND_SELECT--deck-b_blue--stake-stake_red" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["won"] is False @@ -89,6 +90,25 @@ def test_won_true_extraction(self, client: httpx.Client) -> None: response = api(client, "play", {"cards": [0]}) assert response["result"]["won"] is True + def test_won_persists_through_endless_cycle(self, client: httpx.Client) -> None: + """won=true persists across the endless-mode round-transition cycle.""" + # Drive live to a win, then continue; won must stay true at every step. + api(client, "menu") + api( + client, + "start", + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, + ) + api(client, "skip") + api(client, "skip") + api(client, "select") + api(client, "set", {"ante": 8, "chips": 1000000}) + win = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert win["result"]["won"] is True + assert api(client, "cash_out")["result"]["won"] is True + assert api(client, "next_round")["result"]["won"] is True + assert api(client, "select")["result"]["won"] is True + class TestGamestateRound: """Test gamestate round extraction.""" @@ -139,32 +159,39 @@ class TestGamestateBlinds: def test_blinds_structure_extraction(self, client: httpx.Client) -> None: """Test blind extraction structure.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) expected_blinds = { "small": { "type": "SMALL", + "key": "bl_small", "name": "Small Blind", "effect": "", "score": 300, - "tag_effect": "Next base edition shop Joker is free and becomes Polychrome", - "tag_name": "Polychrome Tag", + "tag": { + "key": "tag_polychrome", + "name": "Polychrome Tag", + "effect": "Next base edition shop Joker is free and becomes Polychrome", + }, }, "big": { - "effect": "", + "type": "BIG", + "key": "bl_big", "name": "Big Blind", + "effect": "", "score": 450, - "tag_effect": "After defeating the Boss Blind, gain $25", - "tag_name": "Investment Tag", - "type": "BIG", + "tag": { + "key": "tag_investment", + "name": "Investment Tag", + "effect": "After defeating the Boss Blind, gain $25", + }, }, "boss": { - "effect": "-1 Hand Size", + "type": "BOSS", + "key": "bl_manacle", "name": "The Manacle", + "effect": "-1 Hand Size", "score": 600, - "tag_effect": "", - "tag_name": "", - "type": "BOSS", }, } actual_blinds = { @@ -175,7 +202,7 @@ def test_blinds_structure_extraction(self, client: httpx.Client) -> None: def test_blinds_zero_skip_extraction(self, client: httpx.Client) -> None: """Test initial blind extraction.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["blinds"]["small"]["status"] == "SELECT" assert gamestate["blinds"]["big"]["status"] == "UPCOMING" @@ -183,7 +210,7 @@ def test_blinds_zero_skip_extraction(self, client: httpx.Client) -> None: def test_blinds_one_skip_extraction(self, client: httpx.Client) -> None: """Test blind extraction after one skip.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" load_fixture(client, "gamestate", fixture_name) gamestate = api(client, "skip", {})["result"] assert gamestate["blinds"]["small"]["status"] == "SKIPPED" @@ -192,7 +219,7 @@ def test_blinds_one_skip_extraction(self, client: httpx.Client) -> None: def test_blinds_two_skip_extraction(self, client: httpx.Client) -> None: """Test blind extraction after two skip.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" load_fixture(client, "gamestate", fixture_name) api(client, "skip", {}) gamestate = api(client, "skip", {})["result"] @@ -224,7 +251,9 @@ class TestGamestateAreasJokers: def test_jokers_area_empty_initial(self, client: httpx.Client) -> None: """Test jokers area is empty at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["jokers"]["count"] == 0 assert gamestate["jokers"]["cards"] == [] @@ -239,7 +268,9 @@ def test_jokers_area_count_after_add(self, client: httpx.Client) -> None: def test_jokers_area_limit(self, client: httpx.Client) -> None: """Test jokers area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["jokers"]["limit"] == 5 @@ -248,7 +279,9 @@ class TestGamestateAreasConsumables: def test_consumables_area_empty_initial(self, client: httpx.Client) -> None: """Test consumables area is empty at start of run.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["consumables"]["count"] == 0 assert gamestate["consumables"]["cards"] == [] @@ -263,7 +296,9 @@ def test_consumables_area_count_after_add(self, client: httpx.Client) -> None: def test_consumables_area_limit(self, client: httpx.Client) -> None: """Test consumables area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["consumables"]["limit"] == 2 @@ -272,20 +307,26 @@ class TestGamestateAreasCards: def test_cards_area_initial_count(self, client: httpx.Client) -> None: """Test cards area has full deck at blind selection.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["cards"]["count"] == 52 def test_cards_area_count_after_draw(self, client: httpx.Client) -> None: """Test cards area count after drawing cards.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) load_fixture(client, "gamestate", fixture_name) response = api(client, "select", {}) assert response["result"]["cards"]["count"] == 52 - 8 # 8 cards drawn def test_cards_area_limit(self, client: httpx.Client) -> None: """Test cards area limit.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["cards"]["limit"] == 52 @@ -294,7 +335,9 @@ class TestGamestateAreasHand: def test_hand_area_count_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test hand area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert gamestate["hand"]["count"] == 0 @@ -349,7 +392,9 @@ class TestGamestateAreasShop: def test_shop_area_absent_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test shop area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert "shop" not in gamestate @@ -374,7 +419,9 @@ def test_vouchers_area_absent_in_BLIND_SELECT( self, client: httpx.Client ) -> None: """Test vouchers area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert "vouchers" not in gamestate @@ -397,7 +444,9 @@ class TestGamestateAreasPacks: def test_packs_area_absent_in_BLIND_SELECT(self, client: httpx.Client) -> None: """Test packs area is absent in BLIND_SELECT state.""" - fixture_name = "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE" + fixture_name = ( + "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + ) gamestate = load_fixture(client, "gamestate", fixture_name) assert "packs" not in gamestate @@ -571,7 +620,7 @@ def test_card_set_enhanced(self, client: httpx.Client) -> None: """Test enhanced playing cards have ENHANCED set.""" fixture_name = "state-SELECTING_HAND" load_fixture(client, "gamestate", fixture_name) - response = api(client, "add", {"key": "H_A", "enhancement": "BONUS"}) + response = api(client, "add", {"key": "H_A", "enhancement": "m_bonus"}) # Find the enhanced card (last card in hand) cards = response["result"]["hand"]["cards"] card = cards[-1] @@ -816,6 +865,167 @@ def test_cost_sell_owned_joker(self, client: httpx.Client) -> None: assert joker["cost"]["sell"] > 0 +class TestGamestateUsedVouchers: + """Test gamestate used_vouchers effect text extraction.""" + + @pytest.mark.parametrize( + "voucher_key,expected_effect", + [ + # --- No loc_vars --- + ("v_overstock_norm", "+1 card slot available in shop"), + ("v_overstock_plus", "+1 card slot available in shop"), + ("v_crystal_ball", "+1 consumable slot"), + ( + "v_omen_globe", + "Spectral cards may appear in any of the Arcana Packs", + ), + ( + "v_telescope", + "Celestial Packs always contain the Planet card for your " + "most played poker hand", + ), + ("v_magic_trick", "Playing cards can be purchased from the shop"), + ( + "v_illusion", + "Playing cards in shop may have an Enhancement, Edition, and/or a Seal", + ), + ("v_blank", "Does nothing?"), + ("v_antimatter", "+1 Joker Slot"), + # --- Uses center.config.extra_disp --- + ( + "v_tarot_merchant", + "Tarot cards appear 2X more frequently in the shop", + ), + ( + "v_tarot_tycoon", + "Tarot cards appear 4X more frequently in the shop", + ), + ( + "v_planet_merchant", + "Planet cards appear 2X more frequently in the shop", + ), + ( + "v_planet_tycoon", + "Planet cards appear 4X more frequently in the shop", + ), + # --- Uses center.config.extra --- + ( + "v_hone", + "Foil, Holographic, and Polychrome cards appear 2X more often", + ), + ( + "v_glow_up", + "Foil, Holographic, and Polychrome cards appear 4X more often", + ), + ("v_reroll_surplus", "Rerolls cost $2 less"), + ("v_reroll_glut", "Rerolls cost $2 less"), + ("v_grabber", "Permanently gain +1 hand per round"), + ("v_nacho_tong", "Permanently gain +1 hand per round"), + ("v_wasteful", "Permanently gain +1 discard each round"), + ("v_recyclomancy", "Permanently gain +1 discard each round"), + ("v_clearance_sale", "All cards and packs in shop are 25% off"), + ("v_liquidation", "All cards and packs in shop are 50% off"), + ( + "v_directors_cut", + "Reroll Boss Blind 1 time per Ante, $10 per roll", + ), + ("v_retcon", "Reroll Boss Blind unlimited times, $10 per roll"), + ("v_paint_brush", "+1 hand size"), + ("v_palette", "+1 hand size"), + ("v_hieroglyph", "-1 Ante, -1 hand each round"), + ("v_petroglyph", "-1 Ante, -1 discard each round"), + # --- Uses center.config.extra / 5 --- + ( + "v_seed_money", + "Raise the cap on interest earned in each round to $10", + ), + ( + "v_money_tree", + "Raise the cap on interest earned in each round to $20", + ), + # --- Uses center.config.extra (mult) --- + ( + "v_observatory", + "Planet cards in your consumable area give X1.5 Mult " + "for their specified poker hand", + ), + ], + ids=lambda v: v if v.startswith("v_") else "", + ) + def test_voucher_effect_text( + self, client: httpx.Client, voucher_key: str, expected_effect: str + ) -> None: + """Test that used_vouchers contains correct effect text for each voucher.""" + load_fixture( + client, + "gamestate", + "state-SHOP", + ) + response = api(client, "add", {"key": voucher_key}) + gamestate = assert_gamestate_response(response) + assert gamestate["vouchers"]["cards"][1]["value"]["effect"] == expected_effect + response = api(client, "buy", {"voucher": 1}) + gamestate = assert_gamestate_response(response) + assert voucher_key in gamestate["used_vouchers"] + assert gamestate["used_vouchers"][voucher_key] == expected_effect + + +class TestGamestateTags: + """Test gamestate Tag structure and owned_tags extraction.""" + + def test_blind_tag_structure(self, client: httpx.Client) -> None: + """Test blind tag has key, name, effect fields.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + gamestate = load_fixture(client, "gamestate", fixture_name) + + # Small blind should have a tag + small_tag = gamestate["blinds"]["small"]["tag"] + assert small_tag is not None + assert "key" in small_tag + assert "name" in small_tag + assert "effect" in small_tag + assert small_tag["key"] == "tag_polychrome" + assert small_tag["name"] == "Polychrome Tag" + assert "Polychrome" in small_tag["effect"] + + # Big blind should have a tag + big_tag = gamestate["blinds"]["big"]["tag"] + assert big_tag is not None + assert "key" in big_tag + assert "name" in big_tag + assert "effect" in big_tag + + # Boss blind should not have a tag + assert gamestate["blinds"]["boss"].get("tag") is None + + def test_tags_empty_initially(self, client: httpx.Client) -> None: + """Test tags is empty/not present at start of run.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + gamestate = load_fixture(client, "gamestate", fixture_name) + # tags should not be present when empty + assert "tags" not in gamestate + + def test_tags_populated_after_skip(self, client: httpx.Client) -> None: + """Test tags is populated after skipping a blind.""" + fixture_name = "state-BLIND_SELECT--round_num-0--deck-b_red--stake-stake_white" + load_fixture(client, "gamestate", fixture_name) + + # Skip the small blind to get its tag + response = api(client, "skip", {}) + gamestate = assert_gamestate_response(response) + + # Should now have tags + assert "tags" in gamestate + assert len(gamestate["tags"]) >= 1 + + # Check tag structure + tag = gamestate["tags"][0] + assert "key" in tag + assert "name" in tag + assert "effect" in tag + assert tag["key"].startswith("tag_") + + class TestGamestateCardModifiers: """Test gamestate card modifiers.""" diff --git a/tests/lua/endpoints/test_pack.py b/tests/lua/endpoints/test_pack.py index 1ff2a514..15a77b25 100644 --- a/tests/lua/endpoints/test_pack.py +++ b/tests/lua/endpoints/test_pack.py @@ -158,6 +158,35 @@ def test_pack_joker_slots_full(self, client: httpx.Client) -> None: "Cannot select joker, joker slots are full. Current: 5, Limit: 5", ) + def test_pack_joker_slots_full_sell_joker(self, client: httpx.Client) -> None: + """Test selling a joker to make room when joker slots are full during pack selection.""" + gamestate = load_fixture( + client, + "pack", + "state-SMODS_BOOSTER_OPENED--pack.type-buffoon--jokers.count-5", + ) + assert gamestate["jokers"]["count"] == 5 + before_jokers = set(j["key"] for j in gamestate["jokers"]["cards"]) + result = api(client, "sell", {"joker": 0}) + gamestate = assert_gamestate_response(result) + assert gamestate["jokers"]["count"] == 4 + result = api(client, "pack", {"card": 0}) + gamestate = assert_gamestate_response(result, state="SHOP") + assert gamestate["jokers"]["count"] == 5 + after_jokers = set(j["key"] for j in gamestate["jokers"]["cards"]) + assert before_jokers != after_jokers + + def test_pack_tarot_try_to_sell_joker(self, client: httpx.Client) -> None: + """Test that selling jokers is not allowed when a non-buffoon pack is open.""" + load_fixture( + client, "pack", "state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_heirophant" + ) + assert_error_response( + api(client, "sell", {"joker": 0}), + "NOT_ALLOWED", + "Can only sell jokers when a Buffoon pack is open", + ) + def test_pack_joker_slots_available(self, client: httpx.Client) -> None: """Test selecting joker when slots available succeeds.""" load_fixture( @@ -403,6 +432,40 @@ def test_pack_celestial_black_hole(self, client: httpx.Client) -> None: # Pack should be closed after second selection assert "pack" not in after_second + def test_pack_celestial_black_hole_at_index_zero( + self, client: httpx.Client + ) -> None: + """Regression test for #199: selecting Black Hole from a Celestial pack + where it is the first card must not hang. + + Black Hole appears in Celestial packs via the soul mechanism (0.3% chance). + The bug: pack.lua checks first card's ability.set to decide if hand cards + are needed. Black Hole has set=Spectral -> needs_hand=true. But Celestial + packs don't deal hand cards -> endpoint hangs forever. + """ + gamestate = load_fixture( + client, + "pack", + "seed-S001250--state-SMODS_BOOSTER_OPENED--pack.cards[0].key-c_black_hole", + ) + assert gamestate["state"] == "SMODS_BOOSTER_OPENED" + assert gamestate["pack"]["cards"][0]["key"] == "c_black_hole" + + # Select Black Hole (index 0) — must not hang + result = api(client, "pack", {"card": 0}, timeout=10.0) + after = assert_gamestate_response(result, state="SHOP") + + # Pack should be closed after selection + assert "pack" not in after + + # Black Hole levels up ALL hands by 1 + before = gamestate + for hand_name in before["hands"]: + assert ( + after["hands"][hand_name]["level"] + == before["hands"][hand_name]["level"] + 1 + ) + # ============================================================================= # Mega Pack Multi-Selection Tests @@ -485,6 +548,24 @@ def test_pack_skip(self, client: httpx.Client, pack_key: str) -> None: gamestate = assert_gamestate_response(result, state="SHOP") assert "pack" not in gamestate + def test_pack_skip_from_tag_reward(self, client: httpx.Client) -> None: + # Test skipping a pack opened by Charm Tag returns to BLIND_SELECT. + # + # When skipping a blind with a Charm Tag, a free Mega Arcana Pack opens + # from BLIND_SELECT. After skipping the pack, the game must return to + # BLIND_SELECT (not SHOP) because the pack interrupted that state. + + load_fixture( + client, + "pack", + "seed-TAGTEST2--state-SMODS_BOOSTER_OPENED--blinds.small.tag.key-tag_charm", + ) + + result = api(client, "pack", {"skip": True}) + gamestate = assert_gamestate_response(result) + assert gamestate["state"] == "BLIND_SELECT" + assert "pack" not in gamestate + # ============================================================================= # Schema Validation Tests @@ -570,7 +651,11 @@ def test_pack_from_SHOP(self, client: httpx.Client) -> None: def test_pack_from_SELECTING_HAND(self, client: httpx.Client) -> None: """Test that pack fails from SELECTING_HAND state.""" api(client, "menu", {}) - api(client, "start", {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}) + api( + client, + "start", + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, + ) api(client, "select", {}) assert_error_response( diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py index 8dee5527..1b4680a9 100644 --- a/tests/lua/endpoints/test_play.py +++ b/tests/lua/endpoints/test_play.py @@ -86,6 +86,45 @@ def test_play_valid_cards_and_game_over(self, client: httpx.Client) -> None: response = api(client, "play", {"cards": [0]}, timeout=5) assert_gamestate_response(response, state="GAME_OVER") + def test_play_endless_mode_after_won(self, client: httpx.Client) -> None: + """Endless-mode play runs unpaused after winning ante 8. + + Winning the ante-8 boss raises the win overlay and pauses the game + (``G.SETTINGS.paused = true``). ``play`` must dismiss that overlay so + the endless run keeps running on turbo time; otherwise the game is + left paused indefinitely and every subsequent play reports + ``paused=true``. + """ + # Drive to the ante-8 boss and win it. + api(client, "menu") + api( + client, + "start", + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, + ) + api(client, "skip") + api(client, "skip") + api(client, "select") + api(client, "set", {"ante": 8, "chips": 1000000}) + win = api(client, "play", {"cards": [0, 3, 4, 5, 6]}) + assert_gamestate_response(win, state="ROUND_EVAL") + assert win["result"]["won"] is True + + # Continue into endless mode. + api(client, "cash_out") + api(client, "next_round") + entering = api(client, "select") + assert_gamestate_response(entering, state="SELECTING_HAND") + assert entering["result"]["won"] is True + + # An endless play must run unpaused. If the win overlay was left up, + # the game stays paused forever and the response reports paused=true. + response = api(client, "play", {"cards": [0, 1, 2, 3, 4]}) + assert_gamestate_response(response) + assert response["result"]["paused"] is False, ( + "endless play left the game paused — win overlay not dismissed" + ) + class TestPlayEndpointValidation: """Test play endpoint parameter validation.""" @@ -110,6 +149,34 @@ def test_invalid_cards_type(self, client: httpx.Client): "Field 'cards' must be an array", ) + def test_cerulean_bell_forced_card_not_included_in_play( + self, client: httpx.Client + ) -> None: + """Play a single non-forced card; the forced card must NOT be included.""" + + prev_gs = load_fixture( + client, "play", "state-SELECTING_HAND--blinds.boss.key-bl_final_bell" + ) + assert prev_gs["blinds"]["boss"]["key"] == "bl_final_bell" + + # Find the forced card (highlighted by The Bell) + h_idx, h_card = None, None + for i, c in enumerate(prev_gs["hand"]["cards"]): + if isinstance(c["state"], dict) and c["state"]["highlight"]: + h_idx = i + h_card = c + break + assert h_card is not None, "The Bell should force exactly one card" + assert h_idx is not None, "The Bell should force exactly one card" + + # Select another card to play, this should raise an error in the API. + response = api(client, "play", {"cards": [1 if h_idx == 0 else 0]}) + assert_error_response( + response, + "BAD_REQUEST", + "forced-selected by the boss blind", + ) + class TestPlayEndpointStateRequirements: """Test play endpoint state requirements.""" diff --git a/tests/lua/endpoints/test_screenshot.py b/tests/lua/endpoints/test_screenshot.py index be71e127..0200a776 100644 --- a/tests/lua/endpoints/test_screenshot.py +++ b/tests/lua/endpoints/test_screenshot.py @@ -14,7 +14,7 @@ load_fixture, ) -HEADLESS = os.getenv("BALATROBOT_HEADLESS") == "1" +HEADLESS = os.getenv("BALATROBOT_RENDER") == "headless" @pytest.mark.skipif( diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py index 9090ae91..f8f1e789 100644 --- a/tests/lua/endpoints/test_sell.py +++ b/tests/lua/endpoints/test_sell.py @@ -139,6 +139,20 @@ def test_sell_consumable_in_SHOP(self, client: httpx.Client) -> None: assert after["consumables"]["count"] == 0 assert before["money"] < after["money"] + def test_sell_invisible_joker(self, client: httpx.Client) -> None: + """Selling Invisible Joker should not hang (issue #195).""" + before = load_fixture( + client, + "sell", + "state-SHOP--jokers.count-2--jokers.cards[0].key-j_invisible", + ) + assert before["state"] == "SHOP" + assert before["jokers"]["count"] == 2 + + response = api(client, "sell", {"joker": 0}, timeout=10.0) + after = assert_gamestate_response(response) + assert after["jokers"]["count"] >= 1 + class TestSellEndpointValidation: """Test sell endpoint parameter validation.""" diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py index 65fb900c..08e02776 100644 --- a/tests/lua/endpoints/test_set.py +++ b/tests/lua/endpoints/test_set.py @@ -258,3 +258,117 @@ def test_invalid_shop_type(self, client: httpx.Client): "BAD_REQUEST", "Field 'shop' must be of type boolean", ) + + +class TestSetEndpointBoss: + """Test set endpoint boss blind functionality.""" + + def test_set_boss_not_in_blind_select(self, client: httpx.Client) -> None: + """Test that set boss fails when not in BLIND_SELECT state.""" + gamestate = load_fixture(client, "set", "state-SELECTING_HAND") + assert gamestate["state"] == "SELECTING_HAND" + response = api(client, "set", {"boss": "bl_hook"}) + assert_error_response( + response, + "INVALID_STATE", + "Can only set boss blind during blind selection (BLIND_SELECT state)", + ) + + def test_set_boss_invalid_key(self, client: httpx.Client) -> None: + """Test that set boss fails when key does not exist.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_nonsense"}) + assert_error_response( + response, + "BAD_REQUEST", + "Unknown boss blind key: bl_nonsense", + ) + + def test_set_boss_not_a_boss(self, client: httpx.Client) -> None: + """Test that set boss fails when key is not a boss blind (e.g. bl_small).""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_small"}) + assert_error_response( + response, + "BAD_REQUEST", + "Not a boss blind: bl_small", + ) + + def test_set_boss_success(self, client: httpx.Client) -> None: + """Test that set boss succeeds and gamestate reflects new boss key.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_hook"}) + after = assert_gamestate_response(response, state="BLIND_SELECT") + assert after["blinds"]["boss"]["key"] == "bl_hook" + + def test_set_boss_with_scalar(self, client: httpx.Client) -> None: + """Test that set boss combined with money works.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_hook", "money": 100}) + after = assert_gamestate_response(response, state="BLIND_SELECT", money=100) + assert after["blinds"]["boss"]["key"] == "bl_hook" + + def test_set_boss_with_shop(self, client: httpx.Client) -> None: + """Test that set boss + shop is rejected (mutual exclusion).""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": "bl_hook", "shop": True}) + assert_error_response( + response, + "BAD_REQUEST", + "Cannot set boss and shop at the same time", + ) + + def test_set_boss_invalid_type(self, client: httpx.Client) -> None: + """Test that set boss fails when boss parameter is not a string.""" + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + response = api(client, "set", {"boss": 123}) + assert_error_response( + response, + "BAD_REQUEST", + "Field 'boss' must be of type string", + ) + + +class TestSetEndpointBossIntegration: + """Integration test for boss blind override.""" + + def test_set_boss_integration(self, client: httpx.Client) -> None: + """Set boss → skip small → skip big → select boss → play → verify boss name.""" + # Start in BLIND_SELECT + gamestate = load_fixture(client, "set", "state-BLIND_SELECT") + assert gamestate["state"] == "BLIND_SELECT" + + # Override boss blind to bl_hook (The Hook) + response = api(client, "set", {"boss": "bl_hook"}) + after = assert_gamestate_response(response, state="BLIND_SELECT") + assert after["blinds"]["boss"]["key"] == "bl_hook" + + # Skip small blind + response = api(client, "skip", {}) + after = assert_gamestate_response(response) + assert after["blinds"]["small"]["status"] == "SKIPPED" + + # Skip big blind + response = api(client, "skip", {}) + after = assert_gamestate_response(response) + assert after["blinds"]["big"]["status"] == "SKIPPED" + + # Select boss blind + response = api(client, "select", {}) + after = assert_gamestate_response(response, state="SELECTING_HAND") + assert after["blinds"]["boss"]["status"] == "CURRENT" + assert after["blinds"]["boss"]["key"] == "bl_hook" + assert "Hook" in after["blinds"]["boss"]["name"] + + # Beat the boss: set high chips and play a card + api(client, "set", {"chips": 1000000}) + response = api(client, "play", {"cards": [0]}) + after = assert_gamestate_response(response) + # After beating the boss, we should be in ROUND_EVAL or SHOP + assert after["state"] in ("ROUND_EVAL", "SHOP") diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py index 5a89edc8..1ca8179d 100644 --- a/tests/lua/endpoints/test_skip.py +++ b/tests/lua/endpoints/test_skip.py @@ -20,10 +20,12 @@ def test_skip_small_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["small"]["status"] == "SELECT" + assert "tags" not in gamestate response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["small"]["status"] == "SKIPPED" assert gamestate["blinds"]["big"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" def test_skip_big_blind(self, client: httpx.Client) -> None: """Test skipping Big blind in BLIND_SELECT state.""" @@ -32,10 +34,14 @@ def test_skip_big_blind(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["big"]["status"] == "SELECT" + assert {"tag_polychrome"} == set(k["key"] for k in gamestate["tags"]) response = api(client, "skip", {}) gamestate = assert_gamestate_response(response, state="BLIND_SELECT") assert gamestate["blinds"]["big"]["status"] == "SKIPPED" assert gamestate["blinds"]["boss"]["status"] == "SELECT" + assert {"tag_polychrome", "tag_investment"} == set( + k["key"] for k in gamestate["tags"] + ) def test_skip_big_boss(self, client: httpx.Client) -> None: """Test skipping Boss in BLIND_SELECT state.""" @@ -44,6 +50,10 @@ def test_skip_big_boss(self, client: httpx.Client) -> None: ) assert gamestate["state"] == "BLIND_SELECT" assert gamestate["blinds"]["boss"]["status"] == "SELECT" + assert gamestate["tags"][0]["key"] == "tag_polychrome" + assert {"tag_polychrome", "tag_investment"} == set( + k["key"] for k in gamestate["tags"] + ) assert_error_response( api(client, "skip", {}), "NOT_ALLOWED", diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py index 75024756..d1b5be40 100644 --- a/tests/lua/endpoints/test_start.py +++ b/tests/lua/endpoints/test_start.py @@ -19,46 +19,46 @@ class TestStartEndpoint: @pytest.mark.parametrize( "arguments,expected", [ - # Test basic start with RED deck and WHITE stake + # Test basic start with b_red deck and stake_white stake ( - {"deck": "RED", "stake": "WHITE"}, + {"deck": "b_red", "stake": "stake_white"}, { "state": "BLIND_SELECT", - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "ante_num": 1, "round_num": 0, }, ), - # Test with BLUE deck + # Test with b_blue deck ( - {"deck": "BLUE", "stake": "WHITE"}, + {"deck": "b_blue", "stake": "stake_white"}, { "state": "BLIND_SELECT", - "deck": "BLUE", - "stake": "WHITE", + "deck": "b_blue", + "stake": "stake_white", "ante_num": 1, "round_num": 0, }, ), - # Test with higher stake (BLACK) + # Test with higher stake (stake_black) ( - {"deck": "RED", "stake": "BLACK"}, + {"deck": "b_red", "stake": "stake_black"}, { "state": "BLIND_SELECT", - "deck": "RED", - "stake": "BLACK", + "deck": "b_red", + "stake": "stake_black", "ante_num": 1, "round_num": 0, }, ), # Test with seed ( - {"deck": "RED", "stake": "WHITE", "seed": "TEST123"}, + {"deck": "b_red", "stake": "stake_white", "seed": "TEST123"}, { "state": "BLIND_SELECT", - "deck": "RED", - "stake": "WHITE", + "deck": "b_red", + "stake": "stake_white", "ante_num": 1, "round_num": 0, "seed": "TEST123", @@ -86,7 +86,7 @@ def test_missing_deck_parameter(self, client: httpx.Client): """Test that start fails when deck parameter is missing.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"stake": "WHITE"}) + response = api(client, "start", {"stake": "stake_white"}) assert_error_response( response, "BAD_REQUEST", @@ -97,7 +97,7 @@ def test_missing_stake_parameter(self, client: httpx.Client): """Test that start fails when stake parameter is missing.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "RED"}) + response = api(client, "start", {"deck": "b_red"}) assert_error_response( response, "BAD_REQUEST", @@ -105,32 +105,34 @@ def test_missing_stake_parameter(self, client: httpx.Client): ) def test_invalid_deck_value(self, client: httpx.Client): - """Test that start fails with invalid deck enum.""" + """Test that start fails with invalid deck key.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"}) + response = api( + client, "start", {"deck": "INVALID_DECK", "stake": "stake_white"} + ) assert_error_response( response, "BAD_REQUEST", - "Invalid deck enum. Must be one of:", + "Expected a b_* deck key from G.P_CENTERS", ) def test_invalid_stake_value(self, client: httpx.Client): """Test that start fails when invalid stake enum is provided.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "RED", "stake": "INVALID_STAKE"}) + response = api(client, "start", {"deck": "b_red", "stake": "INVALID_STAKE"}) assert_error_response( response, "BAD_REQUEST", - "Invalid stake enum. Must be one of:", + "Expected a stake_* key from G.P_STAKES", ) def test_invalid_deck_type(self, client: httpx.Client): """Test that start fails when deck is not a string.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": 123, "stake": "WHITE"}) + response = api(client, "start", {"deck": 123, "stake": "stake_white"}) assert_error_response( response, "BAD_REQUEST", @@ -141,7 +143,7 @@ def test_invalid_stake_type(self, client: httpx.Client): """Test that start fails when stake is not a string.""" response = api(client, "menu", {}) assert_gamestate_response(response, state="MENU") - response = api(client, "start", {"deck": "RED", "stake": 1}) + response = api(client, "start", {"deck": "b_red", "stake": 1}) assert_error_response( response, "BAD_REQUEST", @@ -156,7 +158,7 @@ def test_start_from_BLIND_SELECT(self, client: httpx.Client): """Test that start fails when not in MENU state.""" gamestate = load_fixture(client, "start", "state-BLIND_SELECT") assert gamestate["state"] == "BLIND_SELECT" - response = api(client, "start", {"deck": "RED", "stake": "WHITE"}) + response = api(client, "start", {"deck": "b_red", "stake": "stake_white"}) assert_error_response( response, "INVALID_STATE", diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py index 997edb96..b5c24329 100644 --- a/tests/lua/endpoints/test_use.py +++ b/tests/lua/endpoints/test_use.py @@ -75,7 +75,7 @@ def test_use_magician_with_one_card(self, client: httpx.Client) -> None: assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [0]}) after = assert_gamestate_response(response) - assert after["hand"]["cards"][0]["modifier"]["enhancement"] == "LUCKY" + assert after["hand"]["cards"][0]["modifier"]["enhancement"] == "m_lucky" def test_use_magician_with_two_cards(self, client: httpx.Client) -> None: """Test using The Magician with 2 cards.""" @@ -87,8 +87,8 @@ def test_use_magician_with_two_cards(self, client: httpx.Client) -> None: assert gamestate["state"] == "SELECTING_HAND" response = api(client, "use", {"consumable": 1, "cards": [7, 5]}) after = assert_gamestate_response(response) - assert after["hand"]["cards"][5]["modifier"]["enhancement"] == "LUCKY" - assert after["hand"]["cards"][7]["modifier"]["enhancement"] == "LUCKY" + assert after["hand"]["cards"][5]["modifier"]["enhancement"] == "m_lucky" + assert after["hand"]["cards"][7]["modifier"]["enhancement"] == "m_lucky" def test_use_familiar_all_hand(self, client: httpx.Client) -> None: """Test using Familiar (destroys cards, #G.hand.cards > 1).""" diff --git a/uv.lock b/uv.lock index 63c9f727..6b360dda 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,8 @@ version = "1.5.2" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "platformdirs" }, + { name = "tqdm" }, { name = "typer" }, ] @@ -65,6 +67,7 @@ dev = [ { name = "mdformat-gfm-alerts" }, { name = "mdformat-mkdocs" }, { name = "mdformat-simple-breaks" }, + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-llmstxt" }, { name = "mkdocs-material" }, @@ -76,12 +79,13 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-rerunfailures" }, { name = "pytest-xdist", extra = ["psutil"] }, - { name = "tqdm" }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, + { name = "platformdirs", specifier = ">=4.0" }, + { name = "tqdm", specifier = ">=4.67.1" }, { name = "typer", specifier = ">=0.15" }, ] @@ -95,6 +99,7 @@ dev = [ { name = "mdformat-gfm-alerts", specifier = ">=2.0.0" }, { name = "mdformat-mkdocs", specifier = ">=5.1.1" }, { name = "mdformat-simple-breaks", specifier = ">=0.0.1" }, + { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-llmstxt", specifier = ">=0.5.0" }, { name = "mkdocs-material", specifier = ">=9.7.1" }, @@ -106,7 +111,6 @@ test = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-rerunfailures", specifier = ">=16.1" }, { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" }, - { name = "tqdm", specifier = ">=4.67.1" }, ] [[package]] @@ -505,6 +509,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" @@ -692,6 +713,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/d4/10bb14004d3c792811e05e21b5e5dcae805aacb739bd12a0540967b99592/pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2", size = 266143, upload-time = "2025-06-21T17:56:35.356Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -843,28 +873,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -926,27 +955,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" }, - { url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" }, - { url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" }, - { url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" }, - { url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" }, - { url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" }, - { url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" }, - { url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" }, - { url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" }, - { url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" }, - { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, + { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, ] [[package]] @@ -982,6 +1011,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"