diff --git a/.claude/commands/commit-msg.md b/.claude/commands/commit-msg.md
deleted file mode 100644
index 2ea0610..0000000
--- a/.claude/commands/commit-msg.md
+++ /dev/null
@@ -1,43 +0,0 @@
-Generate a conventional commit message for the current staged changes.
-
-Analyze the git diff of staged files and create a commit message following conventional commits specification:
-
-**Format:** `(): `
-
-**Types:**
-
-- feat: new feature
-- fix: bug fix
-- docs: documentation
-- style: formatting, missing semicolons, etc.
-- refactor: code change that neither fixes a bug nor adds a feature
-- test: adding or correcting tests
-- chore: maintenance tasks
-- ci: continuous integration changes
-- revert: reverts a previous commit
-
-**Scopes:**
-
-- api: Lua API and Python API communication
-- log: Logging and Replay functionality
-- bot: Python bot framework and base classes
-- examples: Example bots and usage samples
-- dev: Development tools and environment
-
-**Workflow:**
-
-1. Run `git status` to see overall repository state. If there are are no staged changes, exit.
-2. Run `git diff --staged` to analyze the actual changes
-3. Run `git diff --stat --staged` for summary of changed files
-4. Run `git log --oneline -10` to review recent commit patterns
-5. Choose appropriate type and scope based on changes
-6. Write concise description (50 chars max for first line)
-7. Include body if changes are complex
-8. Return the generated commit message enclosed in triple backticks
-
-**Notes**
-
-- Do not include emojis in the commit message.
-- Do not include `🤖 Generated with [Claude Code](https://claude.ai/code)` in the commit message.
-- If the list is empty, do not add any co-authors
-- Include in the body of the commit message the following line as last line: `# Co-Authored-By: Claude `
diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md
deleted file mode 100644
index 44f2283..0000000
--- a/.claude/commands/commit.md
+++ /dev/null
@@ -1,53 +0,0 @@
-Generate a conventional commit message for the current staged changes.
-
-Analyze the git diff of staged files and create a commit message following conventional commits specification:
-
-**Format:** `(): `
-
-**Types:**
-
-- feat: new feature
-- fix: bug fix
-- docs: documentation
-- style: formatting, missing semicolons, etc.
-- refactor: code change that neither fixes a bug nor adds a feature
-- test: adding or correcting tests
-- chore: maintenance tasks
-- ci: continuous integration changes
-- revert: reverts a previous commit
-
-**Scopes:**
-
-- api: Lua API and Python API communication
-- log: Logging and Replay functionality
-- bot: Python bot framework and base classes
-- examples: Example bots and usage samples
-- dev: Development tools and environment
-
-**Workflow:**
-
-1. Run `git status` to see overall repository state. If there are are no staged changes, exit.
-2. Run `git diff --staged` to analyze the actual changes
-3. Run `git diff --stat --staged` for summary of changed files
-4. Run `git log --oneline -10` to review recent commit patterns
-5. Choose appropriate type and scope based on changes
-6. Write concise description (50 chars max for first line)
-7. Include body if changes are complex
-8. Commit the staged changes with the generated message
-
-**Co-authors**
-Here is the collection of all previous co-authors of the repo as reference (names and emails):
-
-- claude: `Co-Authored-By: Claude `
-
-Here is a list of the co-authors which contributed to this commit:
-
-```
-$ARGUMENTS
-```
-
-**Notes**
-
-- Do not include emojis in the commit message.
-- Do not include `🤖 Generated with [Claude Code](https://claude.ai/code)` in the commit message.
-- If the list is empty, do not add any co-authors
diff --git a/.claude/commands/test.md b/.claude/commands/test.md
deleted file mode 100644
index 48f744e..0000000
--- a/.claude/commands/test.md
+++ /dev/null
@@ -1,3 +0,0 @@
-Run tests following the testing guidelines in CLAUDE.md.
-
-See the Testing section in CLAUDE.md for complete instructions on prerequisites, workflow, and troubleshooting.
diff --git a/.cursor/rules/docs-formatting.mdc b/.cursor/rules/docs-formatting.mdc
deleted file mode 100644
index d872d6c..0000000
--- a/.cursor/rules/docs-formatting.mdc
+++ /dev/null
@@ -1,51 +0,0 @@
----
-globs: docs/**/*.md
-alwaysApply: false
-description: Documentation formatting guidelines for files in the docs directory
----
-
-# Documentation Formatting Guidelines
-
-When writing documentation files in the docs directory:
-
-## Header Structure
-
-- Use minimal header nesting - prefer level 1 (`#`) and level 2 (`##`) headers
-- Level 3 headers (`###`) are allowed when they significantly improve readability and document structure
-- **Method names, function signatures, and similar structured content** should use level 3 headers (`###`) for better navigation and clarity
-- For other sub-sections that would normally be level 3 or deeper, consider using **bold text** or other formatting instead
-- Preserve logical hierarchy and importance in your header structure
-
-## Spacing Requirements
-
-- Always leave a blank line after any title/header
-- Always leave a blank line before any list (bulleted or numbered)
-- This ensures proper visual separation and readability
-
-## Examples
-
-Good formatting:
-```markdown
-# Main Title
-
-Content here...
-
-## Section Title
-
-More content...
-
-### Method Name or Structured Content
-
-Detailed information...
-
-- List item 1
-- List item 2
-```
-
-Avoid:
-```markdown
-# Main Title
-Content immediately after title...
-#### Excessive nesting
-- List without spacing above
-```
diff --git a/.cursor/rules/python-development.mdc b/.cursor/rules/python-development.mdc
deleted file mode 100644
index 141be75..0000000
--- a/.cursor/rules/python-development.mdc
+++ /dev/null
@@ -1,602 +0,0 @@
----
-globs: *.py
-alwaysApply: false
-description: Modern Python development standards for type annotations, docstrings, and code style targeting Python 3.12+
----
-
-# Python Development Standards
-
-Modern Python development guide targeting Python 3.12+ with ruff formatting/linting and basedright type checking.
-
-## Type Annotations
-
-### Modern Collection Types (Python 3.12+)
-
-Use built-in collection types with modern syntax:
-
-**Preferred:**
-```python
-def process_data(items: list[str]) -> dict[str, int]:
- return {item: len(item) for item in items}
-
-def handle_config(data: dict[str, str | int | None]) -> bool:
- return bool(data)
-```
-
-**Avoid:**
-```python
-from typing import Dict, List, Optional, Union
-
-def process_data(items: List[str]) -> Dict[str, int]:
- return {item: len(item) for item in items}
-
-def handle_config(data: Dict[str, Union[str, int, Optional[str]]]) -> bool:
- return bool(data)
-```
-
-### Union Types and Optional Values
-
-Use pipe operator `|` for unions:
-
-**Preferred:**
-```python
-def get_user(user_id: int) -> dict[str, str] | None:
- # Returns user data or None if not found
- pass
-
-def process_value(value: str | int | float) -> str:
- return str(value)
-```
-
-**Avoid:**
-```python
-from typing import Optional, Union
-
-def get_user(user_id: int) -> Optional[Dict[str, str]]:
- pass
-
-def process_value(value: Union[str, int, float]) -> str:
- return str(value)
-```
-
-### Type Aliases (Python 3.12+)
-
-Use the `type` statement for type aliases:
-
-**Preferred:**
-```python
-type UserId = int
-type UserData = dict[str, str | int]
-type ProcessorFunc = callable[[str], str | None]
-
-def create_user(user_id: UserId, data: UserData) -> bool:
- pass
-```
-
-**Avoid:**
-```python
-from typing import TypeAlias, Callable
-
-UserId: TypeAlias = int
-UserData: TypeAlias = Dict[str, Union[str, int]]
-```
-
-### Generic Classes (Python 3.12+)
-
-Use modern generic syntax:
-
-**Preferred:**
-```python
-class Container[T]:
- def __init__(self, value: T) -> None:
- self.value = value
-
- def get(self) -> T:
- return self.value
-```
-
-**Avoid:**
-```python
-from typing import TypeVar, Generic
-
-T = TypeVar('T')
-
-class Container(Generic[T]):
- def __init__(self, value: T) -> None:
- self.value = value
-```
-
-### When to Import from typing
-
-Only import from typing for:
-- `Protocol`, `TypedDict`, `Literal`
-- `Any`, `Never`, `NoReturn`
-- `Final`, `ClassVar`
-- `Self` (when not using Python 3.11+ `typing_extensions`)
-
-## Docstrings (Google Style)
-
-### Function Docstrings with Type Annotations
-
-When using type annotations, omit types from docstring Args and Returns:
-
-**Preferred:**
-```python
-def process_items(items: list[str], max_count: int | None = None) -> dict[str, int]:
- """Process a list of items and return their lengths.
-
- Args:
- items: List of strings to process.
- max_count: Maximum number of items to process. Defaults to None.
-
- Returns:
- Dictionary mapping each item to its length.
-
- Raises:
- ValueError: If items list is empty.
- """
- if not items:
- raise ValueError("Items list cannot be empty")
-
- result = {item: len(item) for item in items}
- if max_count:
- result = dict(list(result.items())[:max_count])
-
- return result
-```
-
-### Class Docstrings
-
-**Preferred:**
-```python
-class DataProcessor[T]:
- """Processes data items of generic type T.
-
- Attributes:
- items: List of items being processed.
- processed_count: Number of items processed so far.
- """
-
- def __init__(self, initial_items: list[T]) -> None:
- """Initialize processor with initial items.
-
- Args:
- initial_items: Initial list of items to process.
- """
- self.items = initial_items
- self.processed_count = 0
-
- def process_next(self) -> T | None:
- """Process the next item in the queue.
-
- Returns:
- The next processed item, or None if queue is empty.
- """
- if self.processed_count >= len(self.items):
- return None
-
- item = self.items[self.processed_count]
- self.processed_count += 1
- return item
-```
-
-### Generator Functions
-
-Use `Yields` instead of `Returns`:
-
-**Preferred:**
-```python
-def generate_items(data: list[dict[str, str]]) -> Iterator[str]:
- """Generate processed items from raw data.
-
- Args:
- data: List of dictionaries containing raw data.
-
- Yields:
- Processed string items.
- """
- for item in data:
- yield item.get("name", "unknown")
-```
-
-### Property Documentation
-
-Document properties in the getter method only:
-
-**Preferred:**
-```python
-@property
-def status(self) -> str:
- """Current processing status.
-
- Returns 'active' when processing, 'idle' when waiting,
- or 'complete' when finished.
- """
- return self._status
-
-@status.setter
-def status(self, value: str) -> None:
- # No docstring needed for setter
- self._status = value
-```
-
-## Integration Patterns
-
-### TypedDict with Docstrings
-
-**Preferred:**
-```python
-from typing import TypedDict
-
-class UserConfig(TypedDict):
- """Configuration for user account settings.
-
- Attributes:
- name: User's display name.
- email: User's email address.
- active: Whether the account is active.
- """
- name: str
- email: str
- active: bool
-
-def update_user(config: UserConfig) -> bool:
- """Update user with provided configuration.
-
- Args:
- config: User configuration data.
-
- Returns:
- True if update successful, False otherwise.
- """
- # Implementation here
- return True
-```
-
-### Protocol with Docstrings
-
-**Preferred:**
-```python
-from typing import Protocol
-
-class Processable(Protocol):
- """Protocol for objects that can be processed."""
-
- def process(self) -> dict[str, str]:
- """Process the object and return results.
-
- Returns:
- Dictionary containing processing results.
- """
- ...
-
-def handle_processable(item: Processable) -> dict[str, str]:
- """Handle any processable item.
-
- Args:
- item: Object that implements Processable protocol.
-
- Returns:
- Processing results from the item.
- """
- return item.process()
-```
-
-## Key Requirements
-
-### Docstring Structure
-- Use triple double quotes (`"""`)
-- First line: brief summary ending with period
-- Leave blank line before detailed description
-- Use proper section order: Args, Returns/Yields, Raises, Example
-
-### Type Annotation Rules
-- Always use modern Python 3.12+ syntax
-- Prefer built-in types over typing module imports
-- Use `|` for unions instead of `Union`
-- Use `type` statement for type aliases
-- Use modern generic class syntax `[T]`
-
-### Integration
-- With type annotations: omit types from docstring Args/Returns
-- Without type annotations: include types in docstring
-- Always document complex return types and exceptions
-- Use consistent naming between type aliases and docstrings
-# Python Development Standards
-
-Modern Python development guide targeting Python 3.12+ with ruff formatting/linting and basedright type checking.
-
-## Type Annotations
-
-### Modern Collection Types (Python 3.12+)
-
-Use built-in collection types with modern syntax:
-
-**Preferred:**
-```python
-def process_data(items: list[str]) -> dict[str, int]:
- return {item: len(item) for item in items}
-
-def handle_config(data: dict[str, str | int | None]) -> bool:
- return bool(data)
-```
-
-**Avoid:**
-```python
-from typing import Dict, List, Optional, Union
-
-def process_data(items: List[str]) -> Dict[str, int]:
- return {item: len(item) for item in items}
-
-def handle_config(data: Dict[str, Union[str, int, Optional[str]]]) -> bool:
- return bool(data)
-```
-
-### Union Types and Optional Values
-
-Use pipe operator `|` for unions:
-
-**Preferred:**
-```python
-def get_user(user_id: int) -> dict[str, str] | None:
- # Returns user data or None if not found
- pass
-
-def process_value(value: str | int | float) -> str:
- return str(value)
-```
-
-**Avoid:**
-```python
-from typing import Optional, Union
-
-def get_user(user_id: int) -> Optional[Dict[str, str]]:
- pass
-
-def process_value(value: Union[str, int, float]) -> str:
- return str(value)
-```
-
-### Type Aliases (Python 3.12+)
-
-Use the `type` statement for type aliases:
-
-**Preferred:**
-```python
-type UserId = int
-type UserData = dict[str, str | int]
-type ProcessorFunc = callable[[str], str | None]
-
-def create_user(user_id: UserId, data: UserData) -> bool:
- pass
-```
-
-**Avoid:**
-```python
-from typing import TypeAlias, Callable
-
-UserId: TypeAlias = int
-UserData: TypeAlias = Dict[str, Union[str, int]]
-```
-
-### Generic Classes (Python 3.12+)
-
-Use modern generic syntax:
-
-**Preferred:**
-```python
-class Container[T]:
- def __init__(self, value: T) -> None:
- self.value = value
-
- def get(self) -> T:
- return self.value
-```
-
-**Avoid:**
-```python
-from typing import TypeVar, Generic
-
-T = TypeVar('T')
-
-class Container(Generic[T]):
- def __init__(self, value: T) -> None:
- self.value = value
-```
-
-### When to Import from typing
-
-Only import from typing for:
-- `Protocol`, `TypedDict`, `Literal`
-- `Any`, `Never`, `NoReturn`
-- `Final`, `ClassVar`
-- `Self` (when not using Python 3.11+ `typing_extensions`)
-
-## Docstrings (Google Style)
-
-### Function Docstrings with Type Annotations
-
-When using type annotations, omit types from docstring Args and Returns:
-
-**Preferred:**
-```python
-def process_items(items: list[str], max_count: int | None = None) -> dict[str, int]:
- """Process a list of items and return their lengths.
-
- Args:
- items: List of strings to process.
- max_count: Maximum number of items to process. Defaults to None.
-
- Returns:
- Dictionary mapping each item to its length.
-
- Raises:
- ValueError: If items list is empty.
- """
- if not items:
- raise ValueError("Items list cannot be empty")
-
- result = {item: len(item) for item in items}
- if max_count:
- result = dict(list(result.items())[:max_count])
-
- return result
-```
-
-### Class Docstrings
-
-**Preferred:**
-```python
-class DataProcessor[T]:
- """Processes data items of generic type T.
-
- Attributes:
- items: List of items being processed.
- processed_count: Number of items processed so far.
- """
-
- def __init__(self, initial_items: list[T]) -> None:
- """Initialize processor with initial items.
-
- Args:
- initial_items: Initial list of items to process.
- """
- self.items = initial_items
- self.processed_count = 0
-
- def process_next(self) -> T | None:
- """Process the next item in the queue.
-
- Returns:
- The next processed item, or None if queue is empty.
- """
- if self.processed_count >= len(self.items):
- return None
-
- item = self.items[self.processed_count]
- self.processed_count += 1
- return item
-```
-
-### Generator Functions
-
-Use `Yields` instead of `Returns`:
-
-**Preferred:**
-```python
-def generate_items(data: list[dict[str, str]]) -> Iterator[str]:
- """Generate processed items from raw data.
-
- Args:
- data: List of dictionaries containing raw data.
-
- Yields:
- Processed string items.
- """
- for item in data:
- yield item.get("name", "unknown")
-```
-
-### Property Documentation
-
-Document properties in the getter method only:
-
-**Preferred:**
-```python
-@property
-def status(self) -> str:
- """Current processing status.
-
- Returns 'active' when processing, 'idle' when waiting,
- or 'complete' when finished.
- """
- return self._status
-
-@status.setter
-def status(self, value: str) -> None:
- # No docstring needed for setter
- self._status = value
-```
-
-## Integration Patterns
-
-### TypedDict with Docstrings
-
-**Preferred:**
-```python
-from typing import TypedDict
-
-class UserConfig(TypedDict):
- """Configuration for user account settings.
-
- Attributes:
- name: User's display name.
- email: User's email address.
- active: Whether the account is active.
- """
- name: str
- email: str
- active: bool
-
-def update_user(config: UserConfig) -> bool:
- """Update user with provided configuration.
-
- Args:
- config: User configuration data.
-
- Returns:
- True if update successful, False otherwise.
- """
- # Implementation here
- return True
-```
-
-### Protocol with Docstrings
-
-**Preferred:**
-```python
-from typing import Protocol
-
-class Processable(Protocol):
- """Protocol for objects that can be processed."""
-
- def process(self) -> dict[str, str]:
- """Process the object and return results.
-
- Returns:
- Dictionary containing processing results.
- """
- ...
-
-def handle_processable(item: Processable) -> dict[str, str]:
- """Handle any processable item.
-
- Args:
- item: Object that implements Processable protocol.
-
- Returns:
- Processing results from the item.
- """
- return item.process()
-```
-
-## Key Requirements
-
-### Docstring Structure
-- Use triple double quotes (`"""`)
-- First line: brief summary ending with period
-- Leave blank line before detailed description
-- Use proper section order: Args, Returns/Yields, Raises, Example
-
-### Type Annotation Rules
-- Always use modern Python 3.12+ syntax
-- Prefer built-in types over typing module imports
-- Use `|` for unions instead of `Union`
-- Use `type` statement for type aliases
-- Use modern generic class syntax `[T]`
-
-### Integration
-- With type annotations: omit types from docstring Args/Returns
-- Without type annotations: include types in docstring
-- Always document complex return types and exceptions
-- Use consistent naming between type aliases and docstrings
diff --git a/.envrc.example b/.envrc.example
deleted file mode 100644
index 957da90..0000000
--- a/.envrc.example
+++ /dev/null
@@ -1,9 +0,0 @@
-# Example .envrc file for direnv
-# Copy this file to .envrc and fill the missing values.
-
-# Load the virtual environment
-source .venv/bin/activate
-
-# Python-specific variables
-export PYTHONUNBUFFERED="1"
-export PYTHONPATH="${PWD}/src:${PYTHONPATH}"
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 5ca1e61..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,2 +0,0 @@
-*.jsonl filter=lfs diff=lfs merge=lfs -text
-tests/lua/endpoints/checkpoints/** filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml
deleted file mode 100644
index 22b16b8..0000000
--- a/.github/workflows/release-pypi.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Release PyPI
-
-on:
- push:
- tags:
- - v*
- workflow_dispatch:
-
-jobs:
- pypi:
- name: Publish to PyPI
- runs-on: ubuntu-latest
- environment:
- name: release
- permissions:
- id-token: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Install uv
- uses: astral-sh/setup-uv@v5
-
- - name: "Set up Python"
- uses: actions/setup-python@v5
- with:
- python-version-file: ".python-version"
-
- - run: uv build
- - run: uv publish --trusted-publishing always
diff --git a/.gitignore b/.gitignore
index 70a10f7..8238ca8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,8 +5,29 @@ __pycache__
runs/*.jsonl
src/lua_old
balatrobot_old.lua
-scripts
dump
coverage.xml
.coverage
balatro
+src/lua_oldish
+tests/lua_old
+balatrobot_oldish.lua
+balatro_oldish.sh
+*.jkr
+smods
+
+# old python balatrobot implementation
+tests/balatrobot
+src/balatrobot
+REFACTOR.md
+OLD_ENDPOINTS.md
+GAMESTATE.md
+ERRORS.md
+ENDPOINT_USE.md
+ENDPOINT_ADD.md
+ENDPOINTS.md
+balatro.sh
+OPEN-RPC_SPEC.md
+docs_old
+scripts_old
+smods.wiki
diff --git a/.mdformat.toml b/.mdformat.toml
index d413733..85ee0b3 100644
--- a/.mdformat.toml
+++ b/.mdformat.toml
@@ -2,10 +2,4 @@ wrap = "keep"
number = true
end_of_line = "lf"
validate = true
-exclude = [
- "balatro/**",
- "CHANGELOG.md",
- "docs/steamodded/**",
- ".venv/**",
- "docs/balatrobot-api.md",
-]
+exclude = ["balatro/**", "CHANGELOG.md", ".venv/**"]
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
deleted file mode 100644
index 1f4ce5b..0000000
--- a/.vscode/extensions.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "recommendations": [
- "editorconfig.editorconfig",
- "sumneko.lua",
- "charliermarsh.ruff",
- "detachhead.basedpyright",
- "ms-python.vscode-pylance",
- "ms-python.python",
- "ms-python.debugpy"
- ]
-}
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index b61056e..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "python.testing.pytestArgs": ["tests"],
- "python.testing.unittestEnabled": false,
- "python.testing.pytestEnabled": true,
- "stylua.targetReleaseVersion": "latest"
-}
diff --git a/CLAUDE.md b/CLAUDE.md
index 24a18b0..38ef387 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,110 +2,129 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-## Development Commands
+## Overview
-### Quick Start with Makefile
+BalatroBot is a framework for Balatro bot development. This repository contains a Lua-based API that communicates with the Balatro game via a TCP server. The API allows external clients (primarily Python-based bots) to control the game, query game state, and execute actions.
-The project includes a comprehensive Makefile with all development workflows. Run `make help` to see all available commands:
-
-```bash
-# Show all available commands with descriptions
-make help
-
-# Quick development workflow (format + lint + typecheck)
-make dev
-
-# Complete workflow including tests
-make all
-
-# Install development dependencies
-make install-dev
-```
-
-### Code Quality and Linting
-
-```bash
-make lint # Check code with ruff linter
-make lint-fix # Auto-fix linting issues
-make format # Format code with ruff and stylua
-make format-md # Format markdown files
-make typecheck # Run type checker
-make quality # Run all quality checks
-```
+**Important**: Focus on the Lua API code in `src/lua/` and `tests/lua/`. Ignore the Python package `src/balatrobot/` and `tests/balatrobot/`.
### Testing
```bash
-make test # Run tests with single instance (auto-starts if needed)
-make test-parallel # Run tests on 4 instances (auto-starts if needed)
-make test-teardown # Kill all Balatro instances
-```
-
-**Testing Features:**
-
-- **Auto-start**: Both `test` and `test-parallel` automatically start Balatro instances if not running
-- **Parallel speedup**: `test-parallel` provides ~4x speedup with 4 workers
-- **Instance management**: Tests keep instances running after completion
-- **Port isolation**: Each worker uses its dedicated Balatro instance (ports 12346-12349)
+# Start Balatro game instance (if you need to restart the game)
+python balatro.py start --fast --debug
-**Usage:**
+# Run all Lua tests (it automatically restarts the game)
+make test
-- `make test` - Simple single-instance testing (auto-handles everything)
-- `make test-parallel` - Fast parallel testing (auto-handles everything)
-- `make test-teardown` - Clean up when done testing
+# Run tests with specific marker (it automatically restarts the game)
+make test PYTEST_MARKER=dev
-**Notes:**
+# Run a single test file (we need to restart the game with `python balatro.py start --fast --debug` if the lua code was changed before running the test)
+pytest tests/lua/endpoints/test_health.py -v
-- Monitor logs for each instance: `tail -f logs/balatro_12346.log`
-- Logs are automatically created in the `logs/` directory with format `balatro_PORT.log`
-
-### Documentation
+# Run a specific test (we need to restart the game with `python balatro.py start --fast --debug` if the lua code was changed before running the test)
+pytest tests/lua/endpoints/test_health.py::TestHealthEndpoint::test_health_from_MENU -v
+```
-```bash
-make docs-serve # Serve documentation locally
-make docs-build # Build documentation
-make docs-clean # Clean documentation build
+**Tip**: When we are focused on a specific test/group of tests (e.g. implementation of tests, understand why a test fails, etc.), we can mark the tests with `@pytest.mark.dev` and run them with `make test PYTEST_MARKER=dev` (so the game is restarted and relevant tests are run). So te `dev` pytest tag is reserved for test we are actually working on.
+
+## Architecture
+
+### Core Components
+
+The Lua API is structured around three core layers:
+
+1. **TCP Server** (`src/lua/core/server.lua`)
+
+ - Single-client TCP server on port 12346 (default)
+ - Non-blocking socket I/O
+ - JSON-only protocol: `{"name": "endpoint", "arguments": {...}}\n`
+ - Max message size: 256 bytes
+ - Ultra-simple: JSON object + newline delimiter
+
+2. **Dispatcher** (`src/lua/core/dispatcher.lua`)
+
+ - Routes requests to endpoints with 4-tier validation:
+ 1. Protocol validation (has name, arguments)
+ 2. Schema validation (via Validator)
+ 3. Game state validation (requires_state check)
+ 4. Endpoint execution (with error handling)
+ - Auto-discovers and registers endpoints at startup (fail-fast)
+ - Converts numeric state values to human-readable names for error messages
+
+3. **Validator** (`src/lua/core/validator.lua`)
+
+ - Schema-based validation for endpoint arguments
+ - Fail-fast: returns first error encountered
+ - Type-strict: no implicit conversions
+ - Supported types: string, integer, boolean, array, table
+ - **Important**: No automatic defaults or range validation (endpoints handle this)
+
+### Endpoint Structure
+
+All endpoints follow this pattern (`src/lua/endpoints/*.lua`):
+
+```lua
+return {
+ name = "endpoint_name",
+ description = "Brief description",
+ schema = {
+ field_name = {
+ type = "string" | "integer" | "boolean" | "array" | "table",
+ required = true | false,
+ items = "integer", -- For array types only
+ description = "Field description",
+ },
+ },
+ requires_state = { G.STATES.SELECTING_HAND }, -- Optional state requirement
+ execute = function(args, send_response)
+ -- Endpoint implementation
+ -- Call send_response() with result or error
+ end,
+}
```
-### Build and Maintenance
+**Key patterns**:
-```bash
-make install # Install package dependencies
-make install-dev # Install with development dependencies
-make build # Build package for distribution
-make clean # Clean all build artifacts and caches
-```
+- Endpoints are stateless modules that return a table
+- Use `send_response()` callback to send results (synchronous or async)
+- For async operations, use `G.E_MANAGER:add_event()` to wait for state transitions
+- Card indices are **0-based** in the API (but Lua uses 1-based indexing internally)
+- Always convert between API (0-based) and internal (1-based) indexing
-## Architecture Overview
+### Error Handling
-BalatroBot is a Python framework for developing automated bots to play the card game Balatro. The architecture consists of three main layers:
+Error codes are defined in `src/lua/utils/errors.lua`:
-### 1. Communication Layer (TCP Protocol)
+- `BAD_REQUEST`: Client sent invalid data (protocol/parameter errors)
+- `INVALID_STATE`: Action not allowed in current game state
+- `NOT_ALLOWED`: Game rules prevent this action
+- `INTERNAL_ERROR`: Server-side failure (runtime/execution errors)
-- **Lua API** (`src/lua/api.lua`): Game-side mod that handles socket communication
-- **TCP Socket Communication**: Real-time bidirectional communication between game and bot
-- **Protocol**: Bot sends "HELLO" → Game responds with JSON state → Bot sends action strings
+Endpoints send errors via:
-### 2. Python Framework Layer (`src/balatrobot/`)
+```lua
+send_response({
+ error = "Human-readable message",
+ error_code = BB_ERROR_NAMES.BAD_REQUEST,
+})
+```
-- **BalatroClient** (`client.py`): TCP client for communicating with game API via JSON messages
-- **Type-Safe Models** (`models.py`): Pydantic models matching Lua game state structure (G, GGame, GHand, etc.)
-- **Enums** (`enums.py`): Game state enums (Actions, Decks, Stakes, State, ErrorCode)
-- **Exception Hierarchy** (`exceptions.py`): Structured error handling with game-specific exceptions
-- **API Communication**: JSON request/response protocol with timeout handling and error recovery
+### Game State Management
-## Development Standards
+The `src/lua/utils/gamestate.lua` module provides:
-- Use modern Python 3.13+ syntax with built-in collection types
-- Type annotations with pipe operator for unions: `str | int | None`
-- Use `type` statement for type aliases
-- Google-style docstrings without type information (since type annotations are present)
-- Modern generic class syntax: `class Container[T]:`
+- `BB_GAMESTATE.get_gamestate()`: Extract complete game state
+- State conversion utilities (deck names, stake names, card data)
+- Special GAME_OVER callback support for async endpoints
-## Project Structure Context
+## Key Files
-- **Dual Implementation**: Both Python framework and Lua game mod
-- **TCP Communication**: Port 12346 for real-time game interaction
-- **MkDocs Documentation**: Comprehensive guides with Material theme
-- **Pytest Testing**: TCP socket testing with fixtures
-- **Development Tools**: Ruff, basedpyright, modern Python tooling
+- `balatrobot.lua`: Entry point that loads all modules and initializes the API
+- `src/lua/core/`: Core infrastructure (server, dispatcher, validator)
+- `src/lua/endpoints/`: API endpoint implementations
+- `src/lua/utils/`: Utilities (gamestate extraction, error definitions, types)
+- `tests/lua/conftest.py`: Test fixtures and helpers
+- `Makefile`: Common development commands
+- `balatro.py`: Game launcher with environment variable setup
diff --git a/Makefile b/Makefile
index 4a1c64a..5eb6f08 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := help
-.PHONY: help install install-dev lint lint-fix format format-md typecheck quality test test-parallel test-migrate test-teardown docs-serve docs-build docs-clean build clean all dev
+.PHONY: help install lint format typecheck quality test all
# Colors for output
YELLOW := \033[33m
@@ -8,19 +8,18 @@ BLUE := \033[34m
RED := \033[31m
RESET := \033[0m
-# Project variables
-PYTHON := python3
-UV := uv
-PYTEST := pytest
-RUFF := ruff
-STYLUA := stylua
-TYPECHECK := basedpyright
-MKDOCS := mkdocs
-MDFORMAT := mdformat
-BALATRO_SCRIPT := ./balatro.sh
+# Test variables
+PYTEST_MARKER ?=
-# Test ports for parallel testing
-TEST_PORTS := 12346 12347 12348 12349
+# OS detection for balatro launcher script
+UNAME_S := $(shell uname -s 2>/dev/null || echo Windows)
+ifeq ($(UNAME_S),Darwin)
+ BALATRO_SCRIPT := python scripts/balatro-macos.py
+else ifeq ($(UNAME_S),Linux)
+ BALATRO_SCRIPT := python scripts/balatro-linux.py
+else
+ BALATRO_SCRIPT := python scripts/balatro-windows.py
+endif
help: ## Show this help message
@echo "$(BLUE)BalatroBot Development Makefile$(RESET)"
@@ -28,128 +27,42 @@ help: ## Show this help message
@echo "$(YELLOW)Available targets:$(RESET)"
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " $(GREEN)%-18s$(RESET) %s\n", $$1, $$2}' $(MAKEFILE_LIST)
-# Installation targets
-install: ## Install package dependencies
- @echo "$(YELLOW)Installing dependencies...$(RESET)"
- $(UV) sync
+install: ## Install balatrobot and all dependencies (including dev)
+ @echo "$(YELLOW)Installing all dependencies...$(RESET)"
+ uv sync --all-extras
-install-dev: ## Install package with development dependencies
- @echo "$(YELLOW)Installing development dependencies...$(RESET)"
- $(UV) sync --all-extras
-
-# Code quality targets
lint: ## Run ruff linter (check only)
@echo "$(YELLOW)Running ruff linter...$(RESET)"
- $(RUFF) check --select I .
- $(RUFF) check .
-
-lint-fix: ## Run ruff linter with auto-fixes
- @echo "$(YELLOW)Running ruff linter with fixes...$(RESET)"
- $(RUFF) check --select I --fix .
- $(RUFF) check --fix .
+ ruff check --fix --select I .
+ ruff check --fix .
-format: ## Run ruff formatter
+format: ## Run ruff and mdformat formatters
@echo "$(YELLOW)Running ruff formatter...$(RESET)"
- $(RUFF) check --select I --fix .
- $(RUFF) format .
+ ruff check --select I --fix .
+ ruff format .
+ @echo "$(YELLOW)Running mdformat formatter...$(RESET)"
+ mdformat ./docs README.md CLAUDE.md
@echo "$(YELLOW)Running stylua formatter...$(RESET)"
- $(STYLUA) src/lua
-
-format-md: ## Run markdown formatter
- @echo "$(YELLOW)Running markdown formatter...$(RESET)"
- $(MDFORMAT) .
+ stylua src/lua
typecheck: ## Run type checker
@echo "$(YELLOW)Running type checker...$(RESET)"
- $(TYPECHECK)
-
-quality: lint format typecheck ## Run all code quality checks
- @echo "$(GREEN) All quality checks completed$(RESET)"
-
-# Testing targets
-test: ## Run tests with single Balatro instance (auto-starts if needed)
- @echo "$(YELLOW)Running tests...$(RESET)"
- @if ! $(BALATRO_SCRIPT) --status | grep -q "12346"; then \
- echo "Starting Balatro on port 12346..."; \
- $(BALATRO_SCRIPT) --headless --fast -p 12346; \
- sleep 1; \
- fi
- $(PYTEST)
-
-test-parallel: ## Run tests in parallel on 4 instances (auto-starts if needed)
- @echo "$(YELLOW)Running parallel tests...$(RESET)"
- @running_count=$$($(BALATRO_SCRIPT) --status | grep -E "($(word 1,$(TEST_PORTS))|$(word 2,$(TEST_PORTS))|$(word 3,$(TEST_PORTS))|$(word 4,$(TEST_PORTS)))" | wc -l); \
- if [ "$$running_count" -ne 4 ]; then \
- echo "Starting Balatro instances on ports: $(TEST_PORTS)"; \
- $(BALATRO_SCRIPT) --headless --fast -p $(word 1,$(TEST_PORTS)) -p $(word 2,$(TEST_PORTS)) -p $(word 3,$(TEST_PORTS)) -p $(word 4,$(TEST_PORTS)); \
- sleep 1; \
- fi
- $(PYTEST) -n 4 --port $(word 1,$(TEST_PORTS)) --port $(word 2,$(TEST_PORTS)) --port $(word 3,$(TEST_PORTS)) --port $(word 4,$(TEST_PORTS)) tests/lua/
+ basedpyright tests/
-test-migrate: ## Run replay.py on all JSONL files in tests/runs/ using 4 parallel instances
- @echo "$(YELLOW)Running replay migration on tests/runs/ files...$(RESET)"
- @running_count=$$($(BALATRO_SCRIPT) --status | grep -E "($(word 1,$(TEST_PORTS))|$(word 2,$(TEST_PORTS))|$(word 3,$(TEST_PORTS))|$(word 4,$(TEST_PORTS)))" | wc -l); \
- if [ "$$running_count" -ne 4 ]; then \
- echo "Starting Balatro instances on ports: $(TEST_PORTS)"; \
- $(BALATRO_SCRIPT) --headless --fast -p $(word 1,$(TEST_PORTS)) -p $(word 2,$(TEST_PORTS)) -p $(word 3,$(TEST_PORTS)) -p $(word 4,$(TEST_PORTS)); \
- sleep 1; \
- fi
- @jsonl_files=$$(find tests/runs -name "*.jsonl" -not -name "*.skip" | sort); \
- if [ -z "$$jsonl_files" ]; then \
- echo "$(RED)No .jsonl files found in tests/runs/$(RESET)"; \
- exit 1; \
- fi; \
- file_count=$$(echo "$$jsonl_files" | wc -l); \
- echo "Found $$file_count .jsonl files to process"; \
- ports=($(TEST_PORTS)); \
- port_idx=0; \
- for file in $$jsonl_files; do \
- port=$${ports[$$port_idx]}; \
- echo "Processing $$file on port $$port..."; \
- $(PYTHON) bots/replay.py --input "$$file" --port $$port & \
- port_idx=$$((port_idx + 1)); \
- if [ $$port_idx -eq 4 ]; then \
- port_idx=0; \
- fi; \
- done; \
- wait; \
- echo "$(GREEN)✓ All replay migrations completed$(RESET)"
+quality: lint typecheck format ## Run all code quality checks
+ @echo "$(GREEN)✓ All checks completed$(RESET)"
-test-teardown: ## Kill all Balatro instances
- @echo "$(YELLOW)Killing all Balatro instances...$(RESET)"
- $(BALATRO_SCRIPT) --kill
- @echo "$(GREEN) All instances stopped$(RESET)"
+fixtures: ## Generate fixtures
+ @echo "$(YELLOW)Starting Balatro...$(RESET)"
+ $(BALATRO_SCRIPT) --fast --debug
+ @echo "$(YELLOW)Generating all fixtures...$(RESET)"
+ python tests/fixtures/generate.py
-# Documentation targets
-docs-serve: ## Serve documentation locally
- @echo "$(YELLOW)Starting documentation server...$(RESET)"
- $(MKDOCS) serve
-
-docs-build: ## Build documentation
- @echo "$(YELLOW)Building documentation...$(RESET)"
- $(MKDOCS) build
-
-docs-clean: ## Clean built documentation
- @echo "$(YELLOW)Cleaning documentation build...$(RESET)"
- rm -rf site/
-
-# Build targets
-build: ## Build package for distribution
- @echo "$(YELLOW)Building package...$(RESET)"
- $(PYTHON) -m build
-
-clean: ## Clean build artifacts and caches
- @echo "$(YELLOW)Cleaning build artifacts...$(RESET)"
- rm -rf build/ dist/ *.egg-info/
- rm -rf .pytest_cache/ .coverage htmlcov/ coverage.xml
- rm -rf .ruff_cache/
- find . -type d -name __pycache__ -exec rm -rf {} +
- find . -type f -name "*.pyc" -delete
- @echo "$(GREEN) Cleanup completed$(RESET)"
-
-# Convenience targets
-dev: format lint typecheck ## Quick development check (no tests)
- @echo "$(GREEN) Development checks completed$(RESET)"
+test: ## Run tests head-less
+ @echo "$(YELLOW)Starting Balatro...$(RESET)"
+ $(BALATRO_SCRIPT) --fast --debug
+ @echo "$(YELLOW)Running tests...$(RESET)"
+ pytest tests/lua $(if $(PYTEST_MARKER),-m "$(PYTEST_MARKER)") -v -s
-all: format lint typecheck test ## Complete quality check with tests
- @echo "$(GREEN) All checks completed successfully$(RESET)"
+all: lint format typecheck test ## Run all code quality checks and tests
+ @echo "$(GREEN)✓ All checks completed$(RESET)"
diff --git a/README.md b/README.md
index fd53cb7..a5ce7c7 100644
--- a/README.md
+++ b/README.md
@@ -4,20 +4,27 @@
-
-
-
-
- A framework for Balatro bot development
+
+ API for developing Balatro bots
---
-BalatroBot is a Python framework designed to help developers create automated bots for the card game Balatro. The framework provides a comprehensive API for interacting with the game, handling game state, making strategic decisions, and executing actions. Whether you're building a simple bot or a sophisticated AI player, BalatroBot offers the tools and structure needed to get started quickly.
+BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing game state and controls for external program interaction. The API provides endpoints for complete game control, including card selection, shop transactions, blind selection, and state management. External clients connect via HTTP POST to execute game actions programmatically.
+
+> [!WARNING]
+> **BalatroBot 1.0.0 introduces breaking changes:**
+>
+> - No longer a Python package (no PyPI releases)
+> - New JSON-RPC 2.0 protocol over HTTP/1.1
+> - Updated endpoints and API structure
+> - Removed game state logging functionality
+>
+> BalatroBot is now a Lua mod that exposes an API for programmatic game control.
## 📚 Documentation
@@ -32,3 +39,35 @@ This project is a fork of the original [balatrobot](https://github.com/besteon/b
- [@giewev](https://github.com/giewev)
The original repository provided the initial API and botting framework that this project has evolved from. We appreciate their work in creating the foundation for Balatro bot development.
+
+## 🚀 Related Projects
+
+
diff --git a/balatro.sh b/balatro.sh
deleted file mode 100755
index 3684a3d..0000000
--- a/balatro.sh
+++ /dev/null
@@ -1,465 +0,0 @@
-#!/bin/bash
-
-# Global variables
-declare -a PORTS=()
-declare -a INSTANCE_PIDS=()
-declare -a FAILED_PORTS=()
-HEADLESS=false
-FAST=false
-AUDIO=false
-RENDER_ON_API=false
-FORCE_KILL=true
-KILL_ONLY=false
-STATUS_ONLY=false
-
-# Platform detection
-case "$OSTYPE" in
-darwin*)
- PLATFORM="macos"
- ;;
-linux-gnu*)
- PLATFORM="linux"
- ;;
-*)
- echo "Error: Unsupported platform: $OSTYPE" >&2
- echo "Supported platforms: macOS, Linux" >&2
- exit 1
- ;;
-esac
-
-# Usage function
-show_usage() {
- cat <&2
- exit 1
- fi
- IFS=',' read -ra port_list <<< "$2"
- for port in "${port_list[@]}"; do
- if ! [[ "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1024 ]] || [[ "$port" -gt 65535 ]]; then
- echo "Error: Port must be a number between 1024 and 65535 (got: $port)" >&2
- exit 1
- fi
- PORTS+=("$port")
- done
- shift 2
- ;;
- --headless)
- HEADLESS=true
- shift
- ;;
- --fast)
- FAST=true
- shift
- ;;
- --audio)
- AUDIO=true
- shift
- ;;
- --render-on-api)
- RENDER_ON_API=true
- shift
- ;;
- --kill)
- KILL_ONLY=true
- shift
- ;;
- --status)
- STATUS_ONLY=true
- shift
- ;;
- -h | --help)
- show_usage
- exit 0
- ;;
- *)
- echo "Error: Unknown option $1" >&2
- show_usage
- exit 1
- ;;
- esac
- done
-
- # Validate arguments based on mode
- if [[ "$KILL_ONLY" == "true" ]]; then
- # In kill mode, no ports are required
- if [[ ${#PORTS[@]} -gt 0 ]]; then
- echo "Error: --kill cannot be used with port specifications" >&2
- show_usage
- exit 1
- fi
- elif [[ "$STATUS_ONLY" == "true" ]]; then
- # In status mode, no ports are required
- if [[ ${#PORTS[@]} -gt 0 ]]; then
- echo "Error: --status cannot be used with port specifications" >&2
- show_usage
- exit 1
- fi
- else
- # In normal mode, use default port 12346 if no port is specified
- if [[ ${#PORTS[@]} -eq 0 ]]; then
- PORTS=(12346)
- fi
- fi
-
- # Remove duplicates from ports array
- local unique_ports=()
- for port in "${PORTS[@]}"; do
- if [[ ! " ${unique_ports[*]} " =~ " ${port} " ]]; then
- unique_ports+=("$port")
- fi
- done
- PORTS=("${unique_ports[@]}")
-
- # Validate mutually exclusive options
- if [[ "$RENDER_ON_API" == "true" ]] && [[ "$HEADLESS" == "true" ]]; then
- echo "Error: --render-on-api and --headless are mutually exclusive" >&2
- echo "Choose one rendering mode:" >&2
- echo " --headless No rendering at all (most efficient)" >&2
- echo " --render-on-api Render only on API calls" >&2
- exit 1
- fi
-}
-
-# Check if a port is available
-check_port_availability() {
- local port=$1
-
- # Check if port is in use
- if lsof -Pi :"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then
- if [[ "$FORCE_KILL" == "true" ]]; then
- lsof -ti:"$port" | xargs kill -9 2>/dev/null
- sleep 1
-
- # Verify port is now free
- if lsof -Pi :"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then
- echo "Error: Could not free port $port" >&2
- return 1
- fi
- else
- return 1
- fi
- fi
- return 0
-}
-
-# Get platform-specific configuration
-get_platform_config() {
- case "$PLATFORM" in
- macos)
- # macOS Steam path and configuration
- STEAM_PATH="/Users/$USER/Library/Application Support/Steam/steamapps/common/Balatro"
- LIBRARY_ENV_VAR="DYLD_INSERT_LIBRARIES"
- LIBRARY_FILE="liblovely.dylib"
- BALATRO_EXECUTABLE="Balatro.app/Contents/MacOS/love"
- PROCESS_PATTERNS=("Balatro\.app" "balatro\.sh")
- ;;
- linux)
- # Linux configuration using Proton (Steam Play)
- PREFIX="$HOME/.steam/steam/steamapps/compatdata/2379780"
- PROTON_DIR="$HOME/.steam/steam/steamapps/common/Proton 9.0 (Beta)"
- EXE="$HOME/.steam/debian-installation/steamapps/common/Balatro/Balatro.exe"
-
- STEAM_PATH="$PROTON_DIR"
- LIBRARY_ENV_VAR="" # Not used on Linux when running via Proton
- LIBRARY_FILE=""
- BALATRO_EXECUTABLE="proton"
- # Patterns of processes that should be terminated when cleaning up existing Balatro instances.
- # Do NOT include "balatro\.sh" – it would match this launcher script and terminate it.
- PROCESS_PATTERNS=("Balatro\.exe" "proton")
- ;;
- *)
- echo "Error: Unsupported platform configuration" >&2
- exit 1
- ;;
- esac
-}
-
-# Create logs directory
-create_logs_directory() {
- if [[ ! -d "logs" ]]; then
- mkdir -p logs
- if [[ $? -ne 0 ]]; then
- echo "Error: Could not create logs directory" >&2
- return 1
- fi
- fi
- return 0
-}
-
-# Kill existing Balatro processes
-kill_existing_processes() {
- # Build platform-specific grep pattern
- local grep_pattern=""
- for i in "${!PROCESS_PATTERNS[@]}"; do
- if [[ $i -eq 0 ]]; then
- grep_pattern="${PROCESS_PATTERNS[$i]}"
- else
- grep_pattern="$grep_pattern|${PROCESS_PATTERNS[$i]}"
- fi
- done
-
- if ps aux | grep -E "($grep_pattern)" | grep -v grep >/dev/null; then
- # Kill processes using platform-specific patterns
- for pattern in "${PROCESS_PATTERNS[@]}"; do
- pkill -f "$pattern" 2>/dev/null
- done
- sleep 2
-
- # Force kill if still running
- if ps aux | grep -E "($grep_pattern)" | grep -v grep >/dev/null; then
- for pattern in "${PROCESS_PATTERNS[@]}"; do
- pkill -9 -f "$pattern" 2>/dev/null
- done
- sleep 1
- fi
- fi
-}
-
-# Start a single Balatro instance
-start_balatro_instance() {
- local port=$1
- local log_file="logs/balatro_${port}.log"
-
- # Remove old log file for this port
- if [[ -f "$log_file" ]]; then
- rm "$log_file"
- fi
-
- # Set environment variables
- export BALATROBOT_PORT="$port"
- if [[ "$HEADLESS" == "true" ]]; then
- export BALATROBOT_HEADLESS=1
- fi
- if [[ "$FAST" == "true" ]]; then
- export BALATROBOT_FAST=1
- fi
- if [[ "$AUDIO" == "true" ]]; then
- export BALATROBOT_AUDIO=1
- fi
- if [[ "$RENDER_ON_API" == "true" ]]; then
- export BALATROBOT_RENDER_ON_API=1
- fi
-
- # Set up platform-specific Balatro configuration
- # Platform-specific launch
- if [[ "$PLATFORM" == "linux" ]]; then
- PREFIX="$HOME/.steam/steam/steamapps/compatdata/2379780"
- PROTON_DIR="$STEAM_PATH"
- EXE="$HOME/.steam/debian-installation/steamapps/common/Balatro/Balatro.exe"
-
- # Steam / Proton context
- export STEAM_COMPAT_CLIENT_INSTALL_PATH="$HOME/.steam/steam"
- export STEAM_COMPAT_DATA_PATH="$PREFIX"
- export SteamAppId=2379780
- export SteamGameId=2379780
- export WINEPREFIX="$PREFIX/pfx"
-
- # load Lovely/SteamModded
- export WINEDLLOVERRIDES="version=n,b"
-
- # Run via Proton
- (cd "$WINEPREFIX" && "$PROTON_DIR/proton" run "$EXE") >"$log_file" 2>&1 &
- local pid=$!
- else
- export ${LIBRARY_ENV_VAR}="${STEAM_PATH}/${LIBRARY_FILE}"
- "${STEAM_PATH}/${BALATRO_EXECUTABLE}" >"$log_file" 2>&1 &
- local pid=$!
- fi
-
- # Verify process started
- sleep 2
- if ! ps -p $pid >/dev/null; then
- echo "ERROR: Balatro instance failed to start on port $port. Check $log_file for details." >&2
- FAILED_PORTS+=("$port")
- return 1
- fi
-
- INSTANCE_PIDS+=("$pid")
- return 0
-}
-
-# Print information about running instances
-print_instance_info() {
- local success_count=0
-
- for i in "${!PORTS[@]}"; do
- local port=${PORTS[$i]}
- local log_file="logs/balatro_${port}.log"
-
- if [[ " ${FAILED_PORTS[*]} " =~ " ${port} " ]]; then
- echo "• Port $port, FAILED, Log: $log_file"
- else
- local pid=${INSTANCE_PIDS[$success_count]}
- echo "• Port $port, PID $pid, Log: $log_file"
- ((success_count++))
- fi
- done
-}
-
-# Show status of running Balatro instances
-show_status() {
- # Build platform-specific grep pattern
- local grep_pattern=""
- for i in "${!PROCESS_PATTERNS[@]}"; do
- if [[ $i -eq 0 ]]; then
- grep_pattern="${PROCESS_PATTERNS[$i]}"
- else
- grep_pattern="$grep_pattern|${PROCESS_PATTERNS[$i]}"
- fi
- done
-
- # Find running Balatro processes
- local running_processes=()
- while IFS= read -r line; do
- running_processes+=("$line")
- done < <(ps aux | grep -E "($grep_pattern)" | grep -v grep | awk '{print $2}')
-
- if [[ ${#running_processes[@]} -eq 0 ]]; then
- echo "No Balatro instances are currently running"
- return 0
- fi
-
- # For each running process, find its listening port
- for pid in "${running_processes[@]}"; do
- local port=""
- local log_file=""
-
- # Use lsof to find listening ports for this PID
- if command -v lsof >/dev/null 2>&1; then
- # Look for TCP listening ports (any port >=1024, matching script validation)
- local ports_output
- ports_output=$(lsof -Pan -p "$pid" -i TCP 2>/dev/null | grep LISTEN | awk '{print $9}' | cut -d: -f2)
-
- # Find the first valid port for this Balatro instance
- while IFS= read -r found_port; do
- if [[ "$found_port" =~ ^[0-9]+$ ]] && [[ "$found_port" -ge 1024 ]] && [[ "$found_port" -le 65535 ]]; then
- port="$found_port"
- log_file="logs/balatro_${port}.log"
- break
- fi
- done <<<"$ports_output"
- fi
-
- # Only display processes that have a listening port (actual Balatro instances)
- if [[ -n "$port" ]]; then
- echo "• Port $port, PID $pid, Log: $log_file"
- fi
- # Skip processes without listening ports - they're not actual Balatro instances
- done
-}
-
-# Cleanup function for signal handling
-cleanup() {
- echo ""
- echo "Script interrupted. Cleaning up..."
- if [[ ${#INSTANCE_PIDS[@]} -gt 0 ]]; then
- echo "Killing running Balatro instances..."
- for pid in "${INSTANCE_PIDS[@]}"; do
- kill "$pid" 2>/dev/null
- done
- fi
- exit 1
-}
-
-# Trap signals for cleanup
-trap cleanup SIGINT SIGTERM
-
-# Main execution
-main() {
- # Get platform configuration
- get_platform_config
-
- # Parse arguments
- parse_arguments "$@"
-
- # Handle kill-only mode
- if [[ "$KILL_ONLY" == "true" ]]; then
- echo "Killing all running Balatro instances..."
- kill_existing_processes
- echo "All Balatro instances have been terminated."
- exit 0
- fi
-
- # Handle status-only mode
- if [[ "$STATUS_ONLY" == "true" ]]; then
- show_status
- exit 0
- fi
-
- # Create logs directory
- if ! create_logs_directory; then
- exit 1
- fi
-
- # Kill existing processes
- kill_existing_processes
-
- # Check port availability and start instances
- local failed_count=0
- for port in "${PORTS[@]}"; do
- if ! check_port_availability "$port"; then
- echo "Error: Port $port is not available" >&2
- FAILED_PORTS+=("$port")
- ((failed_count++))
- continue
- fi
-
- if ! start_balatro_instance "$port"; then
- ((failed_count++))
- continue
- fi
-
- done
-
- # Print final status
- print_instance_info
-
- # Determine exit code
- local success_count=$((${#PORTS[@]} - failed_count))
- if [[ $failed_count -eq 0 ]]; then
- exit 0
- elif [[ $success_count -eq 0 ]]; then
- exit 3
- else
- exit 4
- fi
-}
-
-# Run main function with all arguments
-main "$@"
diff --git a/balatrobot.json b/balatrobot.json
index 295478a..3e93d76 100644
--- a/balatrobot.json
+++ b/balatrobot.json
@@ -3,6 +3,7 @@
"name": "BalatroBot",
"author": [
"S1M0N38",
+ "stirby",
"phughesion",
"besteon",
"giewev"
diff --git a/balatrobot.lua b/balatrobot.lua
index afa0774..6923e3d 100644
--- a/balatrobot.lua
+++ b/balatrobot.lua
@@ -1,17 +1,79 @@
--- Load minimal required files
-assert(SMODS.load_file("src/lua/utils.lua"))()
-assert(SMODS.load_file("src/lua/api.lua"))()
-assert(SMODS.load_file("src/lua/log.lua"))()
-assert(SMODS.load_file("src/lua/settings.lua"))()
+-- Load required files
+assert(SMODS.load_file("src/lua/settings.lua"))() -- define BB_SETTINGS
--- Apply all configuration and Love2D patches FIRST
--- This must run before API.init() to set G.BALATROBOT_PORT
-SETTINGS.setup()
+-- Configure Balatro with appropriate settings from environment variables
+BB_SETTINGS.setup()
--- Initialize API (depends on G.BALATROBOT_PORT being set)
-API.init()
+-- Endpoints for the BalatroBot API
+BB_ENDPOINTS = {
+ -- Health endpoint
+ "src/lua/endpoints/health.lua",
+ -- Gamestate endpoints
+ "src/lua/endpoints/gamestate.lua",
+ -- Save/load endpoints
+ "src/lua/endpoints/save.lua",
+ "src/lua/endpoints/load.lua",
+ -- Screenshot endpoint
+ "src/lua/endpoints/screenshot.lua",
+ -- Game control endpoints
+ "src/lua/endpoints/set.lua",
+ "src/lua/endpoints/add.lua",
+ -- Gameplay endpoints
+ "src/lua/endpoints/menu.lua",
+ "src/lua/endpoints/start.lua",
+ -- Blind selection endpoints
+ "src/lua/endpoints/skip.lua",
+ "src/lua/endpoints/select.lua",
+ -- Play/discard endpoints
+ "src/lua/endpoints/play.lua",
+ "src/lua/endpoints/discard.lua",
+ -- Cash out endpoint
+ "src/lua/endpoints/cash_out.lua",
+ -- Shop endpoints
+ "src/lua/endpoints/next_round.lua",
+ "src/lua/endpoints/reroll.lua",
+ "src/lua/endpoints/buy.lua",
+ -- Rearrange endpoint
+ "src/lua/endpoints/rearrange.lua",
+ -- Sell endpoint
+ "src/lua/endpoints/sell.lua",
+ -- Use consumable endpoint
+ "src/lua/endpoints/use.lua",
+ -- If debug mode is enabled, debugger.lua will load test endpoints
+}
--- Initialize Logger
-LOG.init()
+-- Enable debug mode
+if BB_SETTINGS.debug then
+ assert(SMODS.load_file("src/lua/utils/debugger.lua"))() -- define BB_DEBUG
+ BB_DEBUG.setup()
+end
-sendInfoMessage("BalatroBot loaded - version " .. SMODS.current_mod.version, "BALATROBOT")
+-- Load core modules
+assert(SMODS.load_file("src/lua/core/server.lua"))() -- define BB_SERVER
+assert(SMODS.load_file("src/lua/core/dispatcher.lua"))() -- define BB_DISPATCHER
+
+-- Load gamestate and errors utilities
+BB_GAMESTATE = assert(SMODS.load_file("src/lua/utils/gamestate.lua"))()
+assert(SMODS.load_file("src/lua/utils/errors.lua"))()
+
+-- Initialize Server
+local server_success = BB_SERVER.init()
+if not server_success then
+ return
+end
+
+local dispatcher_ok = BB_DISPATCHER.init(BB_SERVER, BB_ENDPOINTS)
+if not dispatcher_ok then
+ return
+end
+
+-- Hook into love.update to run server update loop and detect GAME_OVER
+local love_update = love.update
+love.update = function(dt) ---@diagnostic disable-line: duplicate-set-field
+ -- Check for GAME_OVER before game logic runs
+ BB_GAMESTATE.check_game_over()
+ love_update(dt)
+ BB_SERVER.update(BB_DISPATCHER)
+end
+
+sendInfoMessage("BalatroBot loaded - version " .. SMODS.current_mod.version, "BB.BALATROBOT")
diff --git a/bots/example.py b/bots/example.py
deleted file mode 100644
index beab664..0000000
--- a/bots/example.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Example usage of the BalatroBot API."""
-
-import logging
-
-from balatrobot.client import BalatroClient
-from balatrobot.exceptions import BalatroError
-
-logger = logging.getLogger(__name__)
-logging.basicConfig(level=logging.INFO)
-
-
-def main():
- """Example of using the new BalatroBot API."""
- logger.info("BalatroBot API Example")
-
- with BalatroClient() as client:
- try:
- client.send_message("go_to_menu", {})
- client.send_message(
- "start_run",
- {"deck": "Red Deck", "stake": 1, "seed": "OOOO155"},
- )
- client.send_message(
- "skip_or_select_blind",
- {"action": "select"},
- )
- client.send_message(
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
- client.send_message("cash_out", {})
- client.send_message("shop", {"action": "next_round"})
- client.send_message("go_to_menu", {})
- logger.info("All actions executed successfully")
-
- except BalatroError as e:
- logger.error(f"API Error: {e}")
- logger.error(f"Error code: {e.error_code}")
-
- except Exception as e:
- logger.error(f"Unexpected error: {e}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/bots/replay.py b/bots/replay.py
deleted file mode 100644
index 3b8b997..0000000
--- a/bots/replay.py
+++ /dev/null
@@ -1,170 +0,0 @@
-"""Simple bot that replays actions from a run save (JSONL file)."""
-
-import argparse
-import json
-import logging
-import sys
-import tempfile
-import time
-from pathlib import Path
-
-from balatrobot.client import BalatroClient
-from balatrobot.exceptions import BalatroError, ConnectionFailedError
-
-logger = logging.getLogger(__name__)
-logging.basicConfig(level=logging.INFO)
-
-
-def format_function_call(function_name: str, arguments: dict) -> str:
- """Format function call in Python syntax for dry run mode."""
- args_str = json.dumps(arguments, indent=None, separators=(",", ": "))
- return f"{function_name}({args_str})"
-
-
-def determine_output_path(output_arg: Path | None, input_path: Path) -> Path:
- """Determine the final output path based on input and output arguments."""
- if output_arg is None:
- return input_path
-
- if output_arg.is_dir():
- return output_arg / input_path.name
- else:
- return output_arg
-
-
-def load_steps_from_jsonl(jsonl_path: Path) -> list[dict]:
- """Load replay steps from JSONL file."""
- if not jsonl_path.exists():
- logger.error(f"File not found: {jsonl_path}")
- sys.exit(1)
-
- try:
- with open(jsonl_path) as f:
- steps = [json.loads(line) for line in f if line.strip()]
- logger.info(f"Loaded {len(steps)} steps from {jsonl_path}")
- return steps
- except json.JSONDecodeError as e:
- logger.error(f"Invalid JSON in file {jsonl_path}: {e}")
- sys.exit(1)
-
-
-def main():
- """Main replay function."""
- parser = argparse.ArgumentParser(
- description="Replay actions from a JSONL run file",
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- )
- parser.add_argument(
- "--input",
- "-i",
- type=Path,
- required=True,
- help="Input JSONL file to replay",
- )
- parser.add_argument(
- "--output",
- "-o",
- type=Path,
- help="Output path for generated run log (directory or .jsonl file). "
- "If directory, uses original filename. If not specified, overwrites input.",
- )
- parser.add_argument(
- "--port",
- "-p",
- type=int,
- default=12346,
- help="Port to connect to BalatroBot API",
- )
- parser.add_argument(
- "--delay",
- type=float,
- default=0.0,
- help="Delay between played moves in seconds",
- )
- parser.add_argument(
- "--dry",
- "-d",
- action="store_true",
- help="Dry run mode: print function calls without executing them",
- )
-
- args = parser.parse_args()
-
- if not args.input.exists():
- logger.error(f"Input file not found: {args.input}")
- sys.exit(1)
-
- if not args.input.suffix == ".jsonl":
- logger.error(f"Input file must be a .jsonl file: {args.input}")
- sys.exit(1)
-
- steps = load_steps_from_jsonl(args.input)
- final_output_path = determine_output_path(args.output, args.input)
- if args.dry:
- logger.info(
- f"Dry run mode: printing {len(steps)} function calls from {args.input}"
- )
- for i, step in enumerate(steps):
- function_name = step["function"]["name"]
- arguments = step["function"]["arguments"]
- print(format_function_call(function_name, arguments))
- time.sleep(args.delay)
- logger.info("Dry run completed")
- return
-
- with tempfile.TemporaryDirectory() as temp_dir:
- temp_output_path = Path(temp_dir) / final_output_path.name
-
- try:
- with BalatroClient(port=args.port) as client:
- logger.info(f"Connected to BalatroBot API on port {args.port}")
- logger.info(f"Replaying {len(steps)} steps from {args.input}")
- if final_output_path != args.input:
- logger.info(f"Output will be saved to: {final_output_path}")
-
- for i, step in enumerate(steps):
- function_name = step["function"]["name"]
- arguments = step["function"]["arguments"]
-
- if function_name == "start_run":
- arguments = arguments.copy()
- arguments["log_path"] = str(temp_output_path)
-
- logger.info(
- f"Step {i + 1}/{len(steps)}: {format_function_call(function_name, arguments)}"
- )
- time.sleep(args.delay)
-
- try:
- response = client.send_message(function_name, arguments)
- logger.debug(f"Response: {response}")
- except BalatroError as e:
- logger.error(f"API error in step {i + 1}: {e}")
- sys.exit(1)
-
- logger.info("Replay completed successfully!")
-
- if temp_output_path.exists():
- final_output_path.parent.mkdir(parents=True, exist_ok=True)
- temp_output_path.rename(final_output_path)
- logger.info(f"Output saved to: {final_output_path}")
- elif final_output_path != args.input:
- logger.warning(
- f"No output file was generated at {temp_output_path}"
- )
-
- except ConnectionFailedError as e:
- logger.error(
- f"Failed to connect to BalatroBot API on port {args.port}: {e}"
- )
- sys.exit(1)
- except KeyboardInterrupt:
- logger.info("Replay interrupted by user")
- sys.exit(0)
- except Exception as e:
- logger.error(f"Unexpected error during replay: {e}")
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..379419f
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,1163 @@
+# BalatroBot API Reference
+
+JSON-RPC 2.0 API for controlling Balatro programmatically.
+
+## Overview
+
+- **Protocol**: JSON-RPC 2.0 over HTTP/1.1
+- **Endpoint**: `http://127.0.0.1:12346` (default)
+- **Content-Type**: `application/json`
+
+### Request Format
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "method_name",
+ "params": { ... },
+ "id": 1
+}
+```
+
+### Response Format
+
+**Success:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "result": { ... },
+ "id": 1
+}
+```
+
+**Error:**
+
+```json
+{
+ "jsonrpc": "2.0",
+ "error": {
+ "code": -32001,
+ "message": "Error description",
+ "data": { "name": "BAD_REQUEST" }
+ },
+ "id": 1
+}
+```
+
+## Quickstart
+
+#### 1. Health Check
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "health", "id": 1}'
+```
+
+#### 2. Get Game State
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "gamestate", "id": 1}'
+```
+
+#### 3. Start a New Run
+
+```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}'
+```
+
+#### 4. Select Blind and Play Cards
+
+```bash
+# Select the blind
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "select", "id": 1}'
+
+# Play cards at indices 0, 1, 2
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "play", "params": {"cards": [0, 1, 2]}, "id": 1}'
+```
+
+## Game States
+
+The game progresses through these states:
+
+```
+MENU ──► BLIND_SELECT ──► SELECTING_HAND ──► ROUND_EVAL ──► SHOP ─┐
+ ▲ │ │
+ │ ▼ │
+ │ GAME_OVER │
+ │ │
+ └─────────────────────────────────────────────────┘
+```
+
+| State | Description |
+| ---------------- | ------------------------------------ |
+| `MENU` | Main menu |
+| `BLIND_SELECT` | Choosing which blind to play or skip |
+| `SELECTING_HAND` | Selecting cards to play or discard |
+| `ROUND_EVAL` | Round complete, ready to cash out |
+| `SHOP` | Shopping phase |
+| `GAME_OVER` | Game ended |
+
+---
+
+## Methods
+
+- [`health`](#health) - Health check endpoint
+- [`gamestate`](#gamestate) - Get the complete current game state
+- [`rpc.discover`](#rpcdiscover) - Returns the OpenRPC specification
+- [`start`](#start) - Start a new game run
+- [`menu`](#menu) - Return to the main menu
+- [`save`](#save) - Save the current run to a file
+- [`load`](#load) - Load a saved run from a file
+- [`select`](#select) - Select the current blind to begin the round
+- [`skip`](#skip) - Skip the current blind (Small or Big only)
+- [`buy`](#buy) - Buy a card, voucher, or pack from the shop
+- [`sell`](#sell) - Sell a joker or consumable
+- [`reroll`](#reroll) - Reroll the shop items
+- [`cash_out`](#cash_out) - Cash out round rewards and transition to shop
+- [`next_round`](#next_round) - Leave the shop and advance to blind selection
+- [`play`](#play) - Play cards from hand
+- [`discard`](#discard) - Discard cards from hand
+- [`rearrange`](#rearrange) - Rearrange cards in hand, jokers, or consumables
+- [`use`](#use) - Use a consumable card
+- [`add`](#add) - Add a card to the game (debug/testing)
+- [`screenshot`](#screenshot) - Take a screenshot of the game
+- [`set`](#set) - Set in-game values (debug/testing)
+
+---
+
+### `health`
+
+Health check endpoint.
+
+**Returns:** `{ "status": "ok" }`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "health", "id": 1}'
+```
+
+---
+
+### `gamestate`
+
+Get the complete current game state.
+
+**Returns:** [GameState](#gamestate-schema) object
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "gamestate", "id": 1}'
+```
+
+---
+
+### `rpc.discover`
+
+Returns the OpenRPC specification for this API.
+
+**Returns:** OpenRPC schema document
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "rpc.discover", "id": 1}'
+```
+
+---
+
+### `start`
+
+Start a new game run.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------- | ------ | -------- | --------------------- |
+| `deck` | string | Yes | [Deck](#deck) to use |
+| `stake` | string | Yes | [Stake](#stake) level |
+| `seed` | string | No | Seed for the run |
+
+**Returns:** [GameState](#gamestate-schema) (state will be `BLIND_SELECT`)
+
+**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `INTERNAL_ERROR`
+
+**Example:**
+
+```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}'
+```
+
+---
+
+### `menu`
+
+Return to the main menu from any state.
+
+**Returns:** [GameState](#gamestate-schema) (state will be `MENU`)
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "menu", "id": 1}'
+```
+
+---
+
+### `save`
+
+Save the current run to a file.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------ | ------ | -------- | ---------------------- |
+| `path` | string | Yes | File path for the save |
+
+**Returns:** `{ "success": true, "path": "..." }`
+
+**Errors:** `INVALID_STATE`, `INTERNAL_ERROR`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "save", "params": {"path": "/tmp/save.jkr"}, "id": 1}'
+```
+
+---
+
+### `load`
+
+Load a saved run from a file.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------ | ------ | -------- | --------------------- |
+| `path` | string | Yes | Path to the save file |
+
+**Returns:** `{ "success": true, "path": "..." }`
+
+**Errors:** `INTERNAL_ERROR`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "load", "params": {"path": "/tmp/save.jkr"}, "id": 1}'
+```
+
+---
+
+### `select`
+
+Select the current blind to begin the round.
+
+**Returns:** [GameState](#gamestate-schema) (state will be `SELECTING_HAND`)
+
+**Errors:** `INVALID_STATE`
+
+**Required State:** `BLIND_SELECT`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "select", "id": 1}'
+```
+
+---
+
+### `skip`
+
+Skip the current blind (Small or Big only).
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `INVALID_STATE`, `NOT_ALLOWED`
+
+**Required State:** `BLIND_SELECT`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "skip", "id": 1}'
+```
+
+---
+
+### `buy`
+
+Buy a card, voucher, or pack from the shop.
+
+**Parameters:** (exactly one required)
+
+| Name | Type | Required | Description |
+| --------- | ------- | -------- | ------------------------------- |
+| `card` | integer | No | 0-based index of card to buy |
+| `voucher` | integer | No | 0-based index of voucher to buy |
+| `pack` | integer | No | 0-based index of pack to buy |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`, `NOT_ALLOWED`
+
+**Required State:** `SHOP`
+
+**Example:**
+
+```bash
+# Buy first card in shop
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "buy", "params": {"card": 0}, "id": 1}'
+```
+
+---
+
+### `sell`
+
+Sell a joker or consumable.
+
+**Parameters:** (exactly one required)
+
+| Name | Type | Required | Description |
+| ------------ | ------- | -------- | ----------------------------------- |
+| `joker` | integer | No | 0-based index of joker to sell |
+| `consumable` | integer | No | 0-based index of consumable to sell |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`, `NOT_ALLOWED`
+
+**Example:**
+
+```bash
+# Sell first joker
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "sell", "params": {"joker": 0}, "id": 1}'
+```
+
+---
+
+### `reroll`
+
+Reroll the shop items (costs money).
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `INVALID_STATE`, `NOT_ALLOWED`
+
+**Required State:** `SHOP`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "reroll", "id": 1}'
+```
+
+---
+
+### `cash_out`
+
+Cash out round rewards and transition to shop.
+
+**Returns:** [GameState](#gamestate-schema) (state will be `SHOP`)
+
+**Errors:** `INVALID_STATE`
+
+**Required State:** `ROUND_EVAL`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "cash_out", "id": 1}'
+```
+
+---
+
+### `next_round`
+
+Leave the shop and advance to blind selection.
+
+**Returns:** [GameState](#gamestate-schema) (state will be `BLIND_SELECT`)
+
+**Errors:** `INVALID_STATE`
+
+**Required State:** `SHOP`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "next_round", "id": 1}'
+```
+
+---
+
+### `play`
+
+Play cards from hand.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------- | --------- | -------- | -------------------------------------------- |
+| `cards` | integer[] | Yes | 0-based indices of cards to play (1-5 cards) |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`
+
+**Required State:** `SELECTING_HAND`
+
+**Example:**
+
+```bash
+# Play cards at positions 0, 2, 4
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "play", "params": {"cards": [0, 2, 4]}, "id": 1}'
+```
+
+---
+
+### `discard`
+
+Discard cards from hand.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------- | --------- | -------- | ----------------------------------- |
+| `cards` | integer[] | Yes | 0-based indices of cards to discard |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`
+
+**Required State:** `SELECTING_HAND`
+
+**Example:**
+
+```bash
+# Discard cards at positions 0 and 1
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "discard", "params": {"cards": [0, 1]}, "id": 1}'
+```
+
+---
+
+### `rearrange`
+
+Rearrange cards in hand, jokers, or consumables.
+
+**Parameters:** (exactly one required)
+
+| Name | Type | Required | Description |
+| ------------- | --------- | -------- | -------------------------------------------------------- |
+| `hand` | integer[] | No | New order of hand cards (permutation of current indices) |
+| `jokers` | integer[] | No | New order of jokers |
+| `consumables` | integer[] | No | New order of consumables |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED`
+
+**Example:**
+
+```bash
+# Reverse a 5-card hand
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "rearrange", "params": {"hand": [4, 3, 2, 1, 0]}, "id": 1}'
+```
+
+---
+
+### `use`
+
+Use a consumable card.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------------ | --------- | -------- | ------------------------------------------------------------------------ |
+| `consumable` | integer | Yes | 0-based index of consumable to use |
+| `cards` | integer[] | No | 0-based indices of target cards (for consumables that require selection) |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED`
+
+**Example:**
+
+```bash
+# Use The Magician on cards 0 and 1
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "use", "params": {"consumable": 0, "cards": [0, 1]}, "id": 1}'
+```
+
+---
+
+### `add`
+
+Add a card to the game (debug/testing).
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------------- | ------- | -------- | ------------------------------------------------------------------- |
+| `key` | string | Yes | [Card key](#card-keys) (e.g., `j_joker`, `c_fool`, `H_A`) |
+| `seal` | string | No | [Seal](#card-modifier-seal) type (playing cards only) |
+| `edition` | string | No | [Edition](#card-modifier-edition) type |
+| `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) |
+| `rental` | boolean | No | Costs $1/round (jokers only) |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`, `INVALID_STATE`
+
+**Example:**
+
+```bash
+# 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}'
+```
+
+---
+
+### `screenshot`
+
+Take a screenshot of the game.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+| ------ | ------ | -------- | ---------------------------- |
+| `path` | string | Yes | File path for PNG screenshot |
+
+**Returns:** `{ "success": true, "path": "..." }`
+
+**Errors:** `INTERNAL_ERROR`
+
+**Example:**
+
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "screenshot", "params": {"path": "/tmp/screenshot.png"}, "id": 1}'
+```
+
+---
+
+### `set`
+
+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) |
+
+**Returns:** [GameState](#gamestate-schema)
+
+**Errors:** `BAD_REQUEST`, `INVALID_STATE`, `NOT_ALLOWED`
+
+**Example:**
+
+```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}'
+```
+
+---
+
+## Schemas
+
+### GameState Schema
+
+The complete game state returned by most methods.
+
+```json
+{
+ "state": "SELECTING_HAND",
+ "round_num": 1,
+ "ante_num": 1,
+ "money": 4,
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "ABC123",
+ "won": false,
+ "used_vouchers": {},
+ "hands": { ... },
+ "round": { ... },
+ "blinds": { ... },
+ "jokers": { ... },
+ "consumables": { ... },
+ "hand": { ... },
+ "shop": { ... },
+ "vouchers": { ... },
+ "packs": { ... }
+}
+```
+
+### Area
+
+Represents a card area (hand, jokers, consumables, shop, etc.).
+
+```json
+{
+ "count": 8,
+ "limit": 8,
+ "highlighted_limit": 5,
+ "cards": [ ... ]
+}
+```
+
+### Card
+
+```json
+{
+ "id": 1,
+ "key": "H_A",
+ "set": "DEFAULT",
+ "label": "Ace of Hearts",
+ "value": {
+ "suit": "H",
+ "rank": "A",
+ "effect": "..."
+ },
+ "modifier": {
+ "seal": null,
+ "edition": null,
+ "enhancement": null,
+ "eternal": false,
+ "perishable": null,
+ "rental": false
+ },
+ "state": {
+ "debuff": false,
+ "hidden": false,
+ "highlight": false
+ },
+ "cost": {
+ "sell": 1,
+ "buy": 0
+ }
+}
+```
+
+### Round
+
+```json
+{
+ "hands_left": 4,
+ "hands_played": 0,
+ "discards_left": 3,
+ "discards_used": 0,
+ "reroll_cost": 5,
+ "chips": 0
+}
+```
+
+### Blind
+
+```json
+{
+ "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"
+}
+```
+
+### Hand (Poker Hand Info)
+
+```json
+{
+ "order": 1,
+ "level": 1,
+ "chips": 10,
+ "mult": 1,
+ "played": 0,
+ "played_this_round": 0,
+ "example": [["H_A", true], ["H_K", true]]
+}
+```
+
+---
+
+## Enums
+
+### 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 |
+
+### 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 |
+
+### Card Value Suit
+
+| Value | Description |
+| ----- | ----------- |
+| `H` | Hearts |
+| `D` | Diamonds |
+| `C` | Clubs |
+| `S` | Spades |
+
+### Card Value Rank
+
+| Value | Description |
+| ----- | ----------- |
+| `2` | Two |
+| `3` | Three |
+| `4` | Four |
+| `5` | Five |
+| `6` | Six |
+| `7` | Seven |
+| `8` | Eight |
+| `9` | Nine |
+| `T` | Ten |
+| `J` | Jack |
+| `Q` | Queen |
+| `K` | King |
+| `A` | Ace |
+
+### Card Set
+
+| Value | Description |
+| ---------- | ----------------------------- |
+| `DEFAULT` | Playing card |
+| `ENHANCED` | Playing card with enhancement |
+| `JOKER` | Joker card |
+| `TAROT` | Tarot consumable |
+| `PLANET` | Planet consumable |
+| `SPECTRAL` | Spectral consumable |
+| `VOUCHER` | Voucher |
+| `BOOSTER` | Booster pack |
+
+### Card Modifier Seal
+
+| 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 |
+
+### Card Modifier Edition
+
+| Value | Description |
+| ------------ | --------------------------------- |
+| `FOIL` | +50 Chips |
+| `HOLO` | +10 Mult |
+| `POLYCHROME` | X1.5 Mult |
+| `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 |
+
+### Blind Type
+
+| Value | Description |
+| ------- | ------------------------------------- |
+| `SMALL` | Can be skipped for a Tag |
+| `BIG` | Can be skipped for a Tag |
+| `BOSS` | Cannot be skipped, has special effect |
+
+### Blind Status
+
+| Value | Description |
+| ---------- | ------------------ |
+| `SELECT` | Can be selected |
+| `CURRENT` | Currently active |
+| `UPCOMING` | Future blind |
+| `DEFEATED` | Previously beaten |
+| `SKIPPED` | Previously skipped |
+
+### Card Keys
+
+Card keys are used with the `add` method and appear in the `key` field of Card objects.
+
+#### Tarot Cards
+
+Consumables that enhance playing cards, change suits, generate other cards, or provide money. Keys use prefix `c_` followed by the card name (e.g., `c_fool`, `c_magician`). 22 cards total.
+
+| Key | Effect |
+| -------------------- | ------------------------------------------------------------------------------- |
+| `c_fool` | Creates the last Tarot or Planet card used during this run (The Fool excluded) |
+| `c_magician` | Enhances 2 selected cards to Lucky Cards |
+| `c_high_priestess` | Creates up to 2 random Planet cards (Must have room) |
+| `c_empress` | Enhances 2 selected cards to Mult Cards |
+| `c_emperor` | Creates up to 2 random Tarot cards (Must have room) |
+| `c_heirophant` | Enhances 2 selected cards to Bonus Cards |
+| `c_lovers` | Enhances 1 selected card into a Wild Card |
+| `c_chariot` | Enhances 1 selected card into a Steel Card |
+| `c_justice` | Enhances 1 selected card into a Glass Card |
+| `c_hermit` | Doubles money (Max of $20) |
+| `c_wheel_of_fortune` | 1 in 4 chance to add Foil, Holographic, or Polychrome edition to a random Joker |
+| `c_strength` | Increases rank of up to 2 selected cards by 1 |
+| `c_hanged_man` | Destroys up to 2 selected cards |
+| `c_death` | Select 2 cards, convert the left card into the right card |
+| `c_temperance` | Gives the total sell value of all current Jokers (Max of $50) |
+| `c_devil` | Enhances 1 selected card into a Gold Card |
+| `c_tower` | Enhances 1 selected card into a Stone Card |
+| `c_star` | Converts up to 3 selected cards to Diamonds |
+| `c_moon` | Converts up to 3 selected cards to Clubs |
+| `c_sun` | Converts up to 3 selected cards to Hearts |
+| `c_judgement` | Creates a random Joker card (Must have room) |
+| `c_world` | Converts up to 3 selected cards to Spades |
+
+#### Planet Cards
+
+Consumables that upgrade poker hand levels, increasing their base Chips and Mult. Keys use prefix `c_` followed by planet names (e.g., `c_mercury`, `c_pluto`). 12 cards total.
+
+| Key | Effect |
+| ------------ | ------------------------------------------------------------- |
+| `c_mercury` | Increases Pair hand value by +1 Mult and +15 Chips |
+| `c_venus` | Increases Three of a Kind hand value by +2 Mult and +20 Chips |
+| `c_earth` | Increases Full House hand value by +2 Mult and +25 Chips |
+| `c_mars` | Increases Four of a Kind hand value by +3 Mult and +30 Chips |
+| `c_jupiter` | Increases Flush hand value by +2 Mult and +15 Chips |
+| `c_saturn` | Increases Straight hand value by +3 Mult and +30 Chips |
+| `c_uranus` | Increases Two Pair hand value by +1 Mult and +20 Chips |
+| `c_neptune` | Increases Straight Flush hand value by +4 Mult and +40 Chips |
+| `c_pluto` | Increases High Card hand value by +1 Mult and +10 Chips |
+| `c_planet_x` | Increases Five of a Kind hand value by +3 Mult and +35 Chips |
+| `c_ceres` | Increases Flush House hand value by +4 Mult and +40 Chips |
+| `c_eris` | Increases Flush Five hand value by +3 Mult and +50 Chips |
+
+#### Spectral Cards
+
+Rare consumables with powerful effects that often come with drawbacks. Can add seals, editions, copy cards, or destroy cards. Keys use prefix `c_` (e.g., `c_familiar`, `c_hex`). 18 cards total.
+
+| Key | Effect |
+| --------------- | ------------------------------------------------------------------- |
+| `c_familiar` | Destroy 1 random card in hand, add 3 random Enhanced face cards |
+| `c_grim` | Destroy 1 random card in hand, add 2 random Enhanced Aces |
+| `c_incantation` | Destroy 1 random card in hand, add 4 random Enhanced numbered cards |
+| `c_talisman` | Add a Gold Seal to 1 selected card |
+| `c_aura` | Add Foil, Holographic, or Polychrome effect to 1 selected card |
+| `c_wraith` | Creates a random Rare Joker, sets money to $0 |
+| `c_sigil` | Converts all cards in hand to a single random suit |
+| `c_ouija` | Converts all cards in hand to a single random rank (-1 hand size) |
+| `c_ectoplasm` | Add Negative to a random Joker, -1 hand size |
+| `c_immolate` | Destroys 5 random cards in hand, gain $20 |
+| `c_ankh` | Create a copy of a random Joker, destroy all other Jokers |
+| `c_deja_vu` | Add a Red Seal to 1 selected card |
+| `c_hex` | Add Polychrome to a random Joker, destroy all other Jokers |
+| `c_trance` | Add a Blue Seal to 1 selected card |
+| `c_medium` | Add a Purple Seal to 1 selected card |
+| `c_cryptid` | Create 2 copies of 1 selected card |
+| `c_soul` | Creates a Legendary Joker (Must have room) |
+| `c_black_hole` | Upgrade every poker hand by 1 level |
+
+#### Joker Cards
+
+Persistent cards that provide scoring bonuses, triggered abilities, or passive effects throughout a run. Keys use prefix `j_` followed by the joker name (e.g., `j_joker`, `j_blueprint`). 150 cards total.
+
+| Key | Effect |
+| -------------------- | ---------------------------------------------------------------------------------------- |
+| `j_joker` | +4 Mult |
+| `j_greedy_joker` | Played Diamond cards give +3 Mult when scored |
+| `j_lusty_joker` | Played Heart cards give +3 Mult when scored |
+| `j_wrathful_joker` | Played Spade cards give +3 Mult when scored |
+| `j_gluttenous_joker` | Played Club cards give +3 Mult when scored |
+| `j_jolly` | +8 Mult if played hand contains a Pair |
+| `j_zany` | +12 Mult if played hand contains a Three of a Kind |
+| `j_mad` | +10 Mult if played hand contains a Two Pair |
+| `j_crazy` | +12 Mult if played hand contains a Straight |
+| `j_droll` | +10 Mult if played hand contains a Flush |
+| `j_sly` | +50 Chips if played hand contains a Pair |
+| `j_wily` | +100 Chips if played hand contains a Three of a Kind |
+| `j_clever` | +80 Chips if played hand contains a Two Pair |
+| `j_devious` | +100 Chips if played hand contains a Straight |
+| `j_crafty` | +80 Chips if played hand contains a Flush |
+| `j_half` | +20 Mult if played hand contains 3 or fewer cards |
+| `j_stencil` | X1 Mult for each empty Joker slot |
+| `j_four_fingers` | All Flushes and Straights can be made with 4 cards |
+| `j_mime` | Retrigger all cards held in hand abilities |
+| `j_credit_card` | Go up to -$20 in debt |
+| `j_ceremonial` | When Blind is selected, destroy Joker to the right and add double its sell value to Mult |
+| `j_banner` | +30 Chips for each remaining discard |
+| `j_mystic_summit` | +15 Mult when 0 discards remaining |
+| `j_marble` | Adds one Stone card to the deck when Blind is selected |
+| `j_loyalty_card` | X4 Mult every 6 hands played |
+| `j_8_ball` | 1 in 4 chance for each played 8 to create a Tarot card when scored |
+| `j_misprint` | +0-23 Mult |
+| `j_dusk` | Retrigger all played cards in final hand of the round |
+| `j_raised_fist` | Adds double the rank of lowest ranked card held in hand to Mult |
+| `j_chaos` | 1 free Reroll per shop |
+| `j_fibonacci` | Each played Ace, 2, 3, 5, or 8 gives +8 Mult when scored |
+| `j_steel_joker` | Gives X0.2 Mult for each Steel Card in your full deck |
+| `j_scary_face` | Played face cards give +30 Chips when scored |
+| `j_abstract` | +3 Mult for each Joker card |
+| `j_delayed_grat` | Earn $2 per discard if no discards are used by end of the round |
+| `j_hack` | Retrigger each played 2, 3, 4, or 5 |
+| `j_pareidolia` | All cards are considered face cards |
+| `j_gros_michel` | +15 Mult, 1 in 6 chance this is destroyed at end of round |
+| `j_even_steven` | Played cards with even rank give +4 Mult when scored |
+| `j_odd_todd` | Played cards with odd rank give +31 Chips when scored |
+| `j_scholar` | Played Aces give +20 Chips and +4 Mult when scored |
+| `j_business` | Played face cards have a 1 in 2 chance to give $2 when scored |
+| `j_supernova` | Adds the number of times poker hand has been played this run to Mult |
+| `j_ride_the_bus` | Gains +1 Mult per consecutive hand played without a scoring face card |
+| `j_space` | 1 in 4 chance to upgrade level of played poker hand |
+| `j_egg` | Gains $3 of sell value at end of round |
+| `j_burglar` | When Blind is selected, gain +3 Hands and lose all discards |
+| `j_blackboard` | X3 Mult if all cards held in hand are Spades or Clubs |
+| `j_runner` | Gains +15 Chips if played hand contains a Straight |
+| `j_ice_cream` | +100 Chips, -5 Chips for every hand played |
+| `j_dna` | If first hand of round has only 1 card, add a permanent copy to deck |
+| `j_splash` | Every played card counts in scoring |
+| `j_blue_joker` | +2 Chips for each remaining card in deck |
+| `j_sixth_sense` | If first hand of round is a single 6, destroy it and create a Spectral card |
+| `j_constellation` | Gains X0.1 Mult every time a Planet card is used |
+| `j_hiker` | Every played card permanently gains +5 Chips when scored |
+| `j_faceless` | Earn $5 if 3 or more face cards are discarded at the same time |
+| `j_green_joker` | +1 Mult per hand played, -1 Mult per discard |
+| `j_superposition` | Create a Tarot card if poker hand contains an Ace and a Straight |
+| `j_todo_list` | Earn $4 if poker hand is a specific hand, changes at end of round |
+| `j_cavendish` | X3 Mult, 1 in 1000 chance this card is destroyed at end of round |
+| `j_card_sharp` | X3 Mult if played poker hand has already been played this round |
+| `j_red_card` | Gains +3 Mult when any Booster Pack is skipped |
+| `j_madness` | When Small/Big Blind is selected, gain X0.5 Mult and destroy a random Joker |
+| `j_square` | Gains +4 Chips if played hand has exactly 4 cards |
+| `j_seance` | If poker hand is a Straight Flush, create a random Spectral card |
+| `j_riff_raff` | When Blind is selected, create 2 Common Jokers |
+| `j_vampire` | Gains X0.1 Mult per scoring Enhanced card played, removes Enhancement |
+| `j_shortcut` | Allows Straights to be made with gaps of 1 rank |
+| `j_hologram` | Gains X0.25 Mult every time a playing card is added to your deck |
+| `j_vagabond` | Create a Tarot card if hand is played with $4 or less |
+| `j_baron` | Each King held in hand gives X1.5 Mult |
+| `j_cloud_9` | Earn $1 for each 9 in your full deck at end of round |
+| `j_rocket` | Earn $1 at end of round, payout increases by $2 when Boss Blind is defeated |
+| `j_obelisk` | Gains X0.2 Mult per consecutive hand without playing most played hand |
+| `j_midas_mask` | All played face cards become Gold cards when scored |
+| `j_luchador` | Sell this card to disable the current Boss Blind |
+| `j_photograph` | First played face card gives X2 Mult when scored |
+| `j_gift` | Add $1 of sell value to every Joker and Consumable at end of round |
+| `j_turtle_bean` | +5 hand size, reduces by 1 each round |
+| `j_erosion` | +4 Mult for each card below deck's starting size |
+| `j_reserved_parking` | Each face card held in hand has a 1 in 2 chance to give $1 |
+| `j_mail` | Earn $5 for each discarded card of a specific rank, changes every round |
+| `j_to_the_moon` | Earn an extra $1 of interest for every $5 at end of round |
+| `j_hallucination` | 1 in 2 chance to create a Tarot card when any Booster Pack is opened |
+| `j_fortune_teller` | +1 Mult per Tarot card used this run |
+| `j_juggler` | +1 hand size |
+| `j_drunkard` | +1 discard each round |
+| `j_stone` | Gives +25 Chips for each Stone Card in your full deck |
+| `j_golden` | Earn $4 at end of round |
+| `j_lucky_cat` | Gains X0.25 Mult every time a Lucky card successfully triggers |
+| `j_baseball` | Uncommon Jokers each give X1.5 Mult |
+| `j_bull` | +2 Chips for each $1 you have |
+| `j_diet_cola` | Sell this card to create a free Double Tag |
+| `j_trading` | If first discard of round has only 1 card, destroy it and earn $3 |
+| `j_flash` | Gains +2 Mult per reroll in the shop |
+| `j_popcorn` | +20 Mult, -4 Mult per round played |
+| `j_trousers` | Gains +2 Mult if played hand contains a Two Pair |
+| `j_ancient` | Each played card with specific suit gives X1.5 Mult, suit changes at end of round |
+| `j_ramen` | X2 Mult, loses X0.01 Mult per card discarded |
+| `j_walkie_talkie` | Each played 10 or 4 gives +10 Chips and +4 Mult when scored |
+| `j_selzer` | Retrigger all cards played for the next 10 hands |
+| `j_castle` | Gains +3 Chips per discarded card of specific suit, changes every round |
+| `j_smiley` | Played face cards give +5 Mult when scored |
+| `j_campfire` | Gains X0.25 Mult for each card sold, resets when Boss Blind is defeated |
+| `j_ticket` | Played Gold cards earn $4 when scored |
+| `j_mr_bones` | Prevents Death if chips scored are at least 25% of required, self destructs |
+| `j_acrobat` | X3 Mult on final hand of round |
+| `j_sock_and_buskin` | Retrigger all played face cards |
+| `j_swashbuckler` | Adds the sell value of all other owned Jokers to Mult |
+| `j_troubadour` | +2 hand size, -1 hand each round |
+| `j_certificate` | When round begins, add a random playing card with a random seal to hand |
+| `j_smeared` | Hearts/Diamonds count as same suit, Spades/Clubs count as same suit |
+| `j_throwback` | X0.25 Mult for each Blind skipped this run |
+| `j_hanging_chad` | Retrigger first played card used in scoring 2 additional times |
+| `j_rough_gem` | Played Diamond cards earn $1 when scored |
+| `j_bloodstone` | 1 in 2 chance for played Heart cards to give X1.5 Mult when scored |
+| `j_arrowhead` | Played Spade cards give +50 Chips when scored |
+| `j_onyx_agate` | Played Club cards give +7 Mult when scored |
+| `j_glass` | Gains X0.75 Mult for every Glass Card that is destroyed |
+| `j_ring_master` | Joker, Tarot, Planet, and Spectral cards may appear multiple times |
+| `j_flower_pot` | X3 Mult if poker hand contains a Diamond, Club, Heart, and Spade card |
+| `j_blueprint` | Copies ability of Joker to the right |
+| `j_wee` | Gains +8 Chips when each played 2 is scored |
+| `j_merry_andy` | +3 discards each round, -1 hand size |
+| `j_oops` | Doubles all listed probabilities |
+| `j_idol` | Each played card of specific rank and suit gives X2 Mult, changes every round |
+| `j_seeing_double` | X2 Mult if played hand has a scoring Club and a card of any other suit |
+| `j_matador` | Earn $8 if played hand triggers the Boss Blind ability |
+| `j_hit_the_road` | Gains X0.5 Mult for every Jack discarded this round |
+| `j_duo` | X2 Mult if played hand contains a Pair |
+| `j_trio` | X3 Mult if played hand contains a Three of a Kind |
+| `j_family` | X4 Mult if played hand contains a Four of a Kind |
+| `j_order` | X3 Mult if played hand contains a Straight |
+| `j_tribe` | X2 Mult if played hand contains a Flush |
+| `j_stuntman` | +250 Chips, -2 hand size |
+| `j_invisible` | After 2 rounds, sell this card to Duplicate a random Joker |
+| `j_brainstorm` | Copies the ability of leftmost Joker |
+| `j_satellite` | Earn $1 at end of round per unique Planet card used this run |
+| `j_shoot_the_moon` | Each Queen held in hand gives +13 Mult |
+| `j_drivers_license` | X3 Mult if you have at least 16 Enhanced cards in your full deck |
+| `j_cartomancer` | Create a Tarot card when Blind is selected |
+| `j_astronomer` | All Planet cards and Celestial Packs in the shop are free |
+| `j_burnt` | Upgrade the level of the first discarded poker hand each round |
+| `j_bootstraps` | +2 Mult for every $5 you have |
+| `j_caino` | Gains X1 Mult when a face card is destroyed |
+| `j_triboulet` | Played Kings and Queens each give X2 Mult when scored |
+| `j_yorick` | Gains X1 Mult every 23 cards discarded |
+| `j_chicot` | Disables effect of every Boss Blind |
+| `j_perkeo` | Creates a Negative copy of 1 random consumable at the end of the shop |
+
+#### Voucher Cards
+
+Permanent upgrades purchased from the shop that provide lasting benefits like extra slots, discounts, or improved odds. Keys use prefix `v_` followed by the voucher name (e.g., `v_grabber`, `v_antimatter`). 32 cards total.
+
+| Key | Effect |
+| ------------------- | ------------------------------------------------------------------------------ |
+| `v_overstock_norm` | +1 card slot available in shop (to 3 slots) |
+| `v_clearance_sale` | All cards and packs in shop are 25% off |
+| `v_hone` | Foil, Holographic, and Polychrome cards appear 2X more often |
+| `v_reroll_surplus` | Rerolls cost $2 less |
+| `v_crystal_ball` | +1 consumable slot |
+| `v_telescope` | Celestial Packs always contain the Planet card for your most played poker hand |
+| `v_grabber` | Permanently gain +1 hand per round |
+| `v_wasteful` | Permanently gain +1 discard each round |
+| `v_tarot_merchant` | Tarot cards appear 2X more frequently in the shop |
+| `v_planet_merchant` | Planet cards appear 2X more frequently in the shop |
+| `v_seed_money` | Raise the cap on interest earned in each round to $10 |
+| `v_blank` | Does nothing? |
+| `v_magic_trick` | Playing cards can be purchased from the shop |
+| `v_hieroglyph` | -1 Ante, -1 hand each round |
+| `v_directors_cut` | Reroll Boss Blind 1 time per Ante, $10 per roll |
+| `v_paint_brush` | +1 hand size |
+| `v_overstock_plus` | +1 card slot available in shop (to 4 slots) |
+| `v_liquidation` | All cards and packs in shop are 50% off |
+| `v_glow_up` | Foil, Holographic, and Polychrome cards appear 4X more often |
+| `v_reroll_glut` | Rerolls cost an additional $2 less |
+| `v_omen_globe` | Spectral cards may appear in any of the Arcana Packs |
+| `v_observatory` | Planet cards in consumable area give X1.5 Mult for their poker hand |
+| `v_nacho_tong` | Permanently gain an additional +1 hand per round |
+| `v_recyclomancy` | Permanently gain an additional +1 discard each round |
+| `v_tarot_tycoon` | Tarot cards appear 4X more frequently in the shop |
+| `v_planet_tycoon` | Planet cards appear 4X more frequently in the shop |
+| `v_money_tree` | Raise the cap on interest earned in each round to $20 |
+| `v_antimatter` | +1 Joker slot |
+| `v_illusion` | Playing cards in shop may have an Enhancement, Edition, and/or a Seal |
+| `v_petroglyph` | -1 Ante again, -1 discard each round |
+| `v_retcon` | Reroll Boss Blind unlimited times, $10 per roll |
+| `v_palette` | +1 hand size again |
+
+#### Playing Cards
+
+Playing cards use the format `{Suit}_{Rank}` where:
+
+- **Suit**: `H` (Hearts), `D` (Diamonds), `C` (Clubs), `S` (Spades)
+- **Rank**: `2`-`9`, `T` (Ten), `J` (Jack), `Q` (Queen), `K` (King), `A` (Ace)
+
+Examples: `H_A` (Ace of Hearts), `S_K` (King of Spades), `D_T` (Ten of Diamonds), `C_7` (Seven of Clubs)
+
+---
+
+## Error Codes
+
+| Code | Name | Description |
+| ------ | ---------------- | ---------------------------------------- |
+| -32000 | `INTERNAL_ERROR` | Server-side failure |
+| -32001 | `BAD_REQUEST` | Invalid parameters or protocol error |
+| -32002 | `INVALID_STATE` | Action not allowed in current game state |
+| -32003 | `NOT_ALLOWED` | Game rules prevent this action |
+
+---
+
+## OpenRPC Specification
+
+For machine-readable API documentation, use the `rpc.discover` method to retrieve the full OpenRPC specification.
diff --git a/docs/assets/balatrobench.svg b/docs/assets/balatrobench.svg
new file mode 100644
index 0000000..0f100db
--- /dev/null
+++ b/docs/assets/balatrobench.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/balatrobot.svg b/docs/assets/balatrobot.svg
index b62c3be..766a946 100644
--- a/docs/assets/balatrobot.svg
+++ b/docs/assets/balatrobot.svg
@@ -1,68 +1,31 @@
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
diff --git a/docs/assets/balatrollm.svg b/docs/assets/balatrollm.svg
new file mode 100644
index 0000000..9e974a4
--- /dev/null
+++ b/docs/assets/balatrollm.svg
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/balatrobot-api.md b/docs/balatrobot-api.md
deleted file mode 100644
index 8cc6f9a..0000000
--- a/docs/balatrobot-api.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# BalatroBot API
-
-This page provides comprehensive API documentation for the BalatroBot Python framework. The API enables you to build automated bots that interact with the Balatro card game through a structured TCP communication protocol.
-
-The API is organized into several key components: the `BalatroClient` for managing game connections and sending commands, enums that define game states and actions, exception classes for robust error handling, and data models that structure requests and responses between your bot and the game.
-
-## Client
-
-The `BalatroClient` is the main interface for communicating with the Balatro game through TCP connections. It handles connection management, message serialization, and error handling.
-
-::: balatrobot.client.BalatroClient
- options:
- heading_level: 3
- show_source: true
-
----
-
-## Enums
-
-::: balatrobot.enums.State
- options:
- heading_level: 3
- show_source: true
-::: balatrobot.enums.Actions
- options:
- heading_level: 3
- show_source: true
-::: balatrobot.enums.Decks
- options:
- heading_level: 3
- show_source: true
-::: balatrobot.enums.Stakes
- options:
- heading_level: 3
- show_source: true
-::: balatrobot.enums.ErrorCode
- options:
- heading_level: 3
- show_source: true
-
----
-
-## Exceptions
-
-### Connection and Socket Errors
-
-::: balatrobot.exceptions.SocketCreateFailedError
-::: balatrobot.exceptions.SocketBindFailedError
-::: balatrobot.exceptions.ConnectionFailedError
-
-### Game State and Logic Errors
-
-::: balatrobot.exceptions.InvalidGameStateError
-::: balatrobot.exceptions.InvalidActionError
-::: balatrobot.exceptions.DeckNotFoundError
-::: balatrobot.exceptions.InvalidCardIndexError
-::: balatrobot.exceptions.NoDiscardsLeftError
-
-### API and Parameter Errors
-
-::: balatrobot.exceptions.InvalidJSONError
-::: balatrobot.exceptions.MissingNameError
-::: balatrobot.exceptions.MissingArgumentsError
-::: balatrobot.exceptions.UnknownFunctionError
-::: balatrobot.exceptions.InvalidArgumentsError
-::: balatrobot.exceptions.InvalidParameterError
-::: balatrobot.exceptions.ParameterOutOfRangeError
-::: balatrobot.exceptions.MissingGameObjectError
-
----
-
-## Models
-
-The BalatroBot API uses Pydantic models to provide type-safe data structures that exactly match the game's internal state representation. All models inherit from `BalatroBaseModel` which provides consistent validation and serialization.
-
-#### Base Model
-
-::: balatrobot.models.BalatroBaseModel
-
-### Request Models
-
-These models define the structure for specific API requests:
-
-::: balatrobot.models.StartRunRequest
-::: balatrobot.models.BlindActionRequest
-::: balatrobot.models.HandActionRequest
-::: balatrobot.models.ShopActionRequest
-
-### Game State Models
-
-The game state models provide comprehensive access to all Balatro game information, structured hierarchically to match the Lua API:
-
-#### Root Game State
-
-::: balatrobot.models.G
-
-#### Game Information
-
-::: balatrobot.models.GGame
-::: balatrobot.models.GGameCurrentRound
-::: balatrobot.models.GGameLastBlind
-::: balatrobot.models.GGamePreviousRound
-::: balatrobot.models.GGameProbabilities
-::: balatrobot.models.GGamePseudorandom
-::: balatrobot.models.GGameRoundBonus
-::: balatrobot.models.GGameRoundScores
-::: balatrobot.models.GGameSelectedBack
-::: balatrobot.models.GGameShop
-::: balatrobot.models.GGameStartingParams
-::: balatrobot.models.GGameTags
-
-#### Hand Management
-
-::: balatrobot.models.GHand
-::: balatrobot.models.GHandCards
-::: balatrobot.models.GHandCardsBase
-::: balatrobot.models.GHandCardsConfig
-::: balatrobot.models.GHandCardsConfigCard
-::: balatrobot.models.GHandConfig
-
-#### Joker Information
-
-::: balatrobot.models.GJokersCards
-::: balatrobot.models.GJokersCardsConfig
-
-### Communication Models
-
-These models handle the communication protocol between your bot and the game:
-
-::: balatrobot.models.APIRequest
-::: balatrobot.models.APIResponse
-::: balatrobot.models.ErrorResponse
-::: balatrobot.models.JSONLLogEntry
-
-## Usage Examples
-
-For practical implementation examples:
-
-- Follow the [Developing Bots](developing-bots.md) guide for complete bot setup
-- Understand the underlying [Protocol API](protocol-api.md) for advanced usage
-- Reference the [Installation](installation.md) guide for environment setup
diff --git a/docs/contributing.md b/docs/contributing.md
index 1f99db1..255e627 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -1,276 +1,130 @@
-# Contributing to BalatroBot
+# Contributing
-Welcome to BalatroBot! We're excited that you're interested in contributing to this Python framework and Lua mod for creating automated bots to play Balatro.
+Guide for contributing to BalatroBot development.
-BalatroBot uses a dual-architecture approach with a Python framework that communicates with a Lua mod running inside Balatro via TCP sockets. This allows for real-time bot automation and game state analysis.
+## Prerequisites
-## Project Status & Priorities
+- **Balatro** (v1.0.1+)
+- **Lovely Injector** - [Installation](https://github.com/ethangreen-dev/lovely-injector)
+- **Steamodded** - [Installation](https://github.com/Steamopollys/Steamodded)
+- **DebugPlus** (optional) - Required for test endpoints
-We track all development work using the [BalatroBot GitHub Project](https://github.com/orgs/coder/projects). This is the best place to see current priorities, ongoing work, and opportunities for contribution.
+## Development Setup
-## Getting Started
+### 1. Clone the Repository
-### Prerequisites
-
-Before contributing, ensure you have:
-
-- **Balatro**: Version 1.0.1o-FULL
-- **SMODS (Steamodded)**: Version 1.0.0-beta-0711a or newer
-- **Python**: 3.13+ (managed via uv)
-- **uv**: Python package manager ([Installation Guide](https://docs.astral.sh/uv/))
-- **OS**: macOS, Linux. Windows is not currently supported
-- **[DebugPlus](https://github.com/WilsontheWolf/DebugPlus) (optional)**: useful for Lua API development and debugging
-
-### Development Environment Setup
-
-1. **Fork and Clone**
-
- ```bash
- git clone https://github.com/YOUR_USERNAME/balatrobot.git
- cd balatrobot
- ```
-
-2. **Install Dependencies**
-
- ```bash
- make install-dev
- ```
-
-3. **Start Balatro with Mods**
-
- ```bash
- ./balatro.sh -p 12346
- ```
-
-4. **Verify Balatro is Running**
-
- ```bash
- # Check if Balatro is running
- ./balatro.sh --status
-
- # Monitor startup logs
- tail -n 100 logs/balatro_12346.log
- ```
-
- Look for these success indicators:
-
- - "BalatrobotAPI initialized"
- - "BalatroBot loaded - version X.X.X"
- - "TCP socket created on port 12346"
-
-## How to Contribute
-
-### Types of Contributions Welcome
-
-- **Bug Fixes**: Issues tracked in our GitHub project
-- **Feature Development**: New bot strategies, API enhancements
-- **Performance Improvements**: Optimization of TCP communication or game interaction
-- **Documentation**: Improvements to guides, API documentation, or examples
-- **Testing**: Additional test coverage, edge case handling
-
-### Contribution Workflow
-
-1. **Check Issues First** (Highly Encouraged)
-
- - Browse the [BalatroBot GitHub Project](https://github.com/orgs/coder/projects)
- - Comment on issues you'd like to work on
- - Create new issues for bugs or feature requests
-
-2. **Fork & Branch**
-
- ```bash
- git checkout -b feature/your-feature-name
- ```
-
-3. **Make Changes**
-
- - Follow our code style guidelines (see below)
- - Add tests for new functionality
- - Update documentation as needed
-
-4. **Create Pull Request**
-
- - **Important**: Enable "Allow edits from maintainers" when creating your PR
- - Link to related issues
- - Provide clear description of changes
- - Include tests for new functionality
-
-### Commit Messages
-
-We highly encourage following [Conventional Commits](https://www.conventionalcommits.org/) format:
-
-```
-feat(api): add new game state detection
-fix(tcp): resolve connection timeout issues
-docs(readme): update setup instructions
-test(api): add shop booster validation tests
+```bash
+git clone https://github.com/your-repo/balatrobot.git
+cd balatrobot
```
-## Development & Testing
+### 2. Symlink to Mods Folder
-### Makefile Commands
+Instead of copying files, create a symlink for easier development:
-BalatroBot includes a comprehensive Makefile that provides a convenient interface for all development tasks. Use `make help` to see all available commands:
+**macOS:**
```bash
-# Show all available commands with descriptions
-make help
+ln -s "$(pwd)" ~/Library/Application\ Support/Balatro/Mods/balatrobot
```
-#### Installation & Setup
+**Linux:**
```bash
-make install # Install package dependencies
-make install-dev # Install with development dependencies
+ln -s "$(pwd)" ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/
```
-#### Code Quality & Formatting
+**Windows (PowerShell as Admin):**
-```bash
-make lint # Run ruff linter (check only)
-make lint-fix # Run ruff linter with auto-fixes
-make format # Run ruff formatter and stylua
-make format-md # Run markdown formatter
-make typecheck # Run type checker
-make quality # Run all code quality checks
-make dev # Quick development check (format + lint + typecheck, no tests)
+```powershell
+New-Item -ItemType SymbolicLink -Path "$env:APPDATA\Balatro\Mods\balatrobot" -Target (Get-Location)
```
-### Testing Requirements
-
-#### Testing with Makefile
+### 3. Set Environment Variables
```bash
-make test # Run tests with single instance (auto-starts if needed)
-make test-parallel # Run tests on 4 instances (auto-starts if needed)
-make test-teardown # Kill all Balatro instances
-
-# Complete workflow including tests
-make all # Run format + lint + typecheck + test
+export BALATROBOT_DEBUG=1
+export BALATROBOT_FAST=1
```
-The testing system automatically handles Balatro instance management:
+### 4. Launch Balatro
-- **`make test`**: Runs tests with a single instance, auto-starting if needed
-- **`make test-parallel`**: Runs tests on 4 instances for ~4x speedup, auto-starting if needed
-- **`make test-teardown`**: Cleans up all instances when done
+Start the game normally. Check logs for "BalatroBot API initialized" to confirm the mod loaded.
-Both test commands keep instances running after completion for faster subsequent runs.
+## Running Tests
-#### Using Checkpoints for Test Setup
-
-The checkpointing system allows you to save and load specific game states, significantly speeding up test setup:
-
-**Creating Test Checkpoints:**
+Tests use Python + pytest to communicate with the Lua API:
```bash
-# Create a checkpoint at a specific game state
-python scripts/create_test_checkpoint.py shop tests/lua/endpoints/checkpoints/shop_state.jkr
-python scripts/create_test_checkpoint.py blind_select tests/lua/endpoints/checkpoints/blind_select.jkr
-python scripts/create_test_checkpoint.py in_game tests/lua/endpoints/checkpoints/in_game.jkr
-```
+# Install all dependencies
+make install
-**Using Checkpoints in Tests:**
+# Run all tests (restarts game automatically)
+make test
-```python
-# In conftest.py or test files
-from ..conftest import prepare_checkpoint
+# Run specific test file
+pytest tests/lua/endpoints/test_health.py -v
-def setup_and_teardown(tcp_client):
- # Load a checkpoint directly (no restart needed!)
- checkpoint_path = Path(__file__).parent / "checkpoints" / "shop_state.jkr"
- game_state = prepare_checkpoint(tcp_client, checkpoint_path)
- assert game_state["state"] == State.SHOP.value
+# Run tests with dev marker
+make test PYTEST_MARKER=dev
```
-**Benefits of Checkpoints:**
-
-- **Faster Tests**: Skip manual game setup steps (particularly helpful for edge cases)
-- **Consistency**: Always start from exact same state
-- **Reusability**: Share checkpoints across multiple tests
-- **No Restarts**: Uses `load_save` API to load directly from any game state
-
-**Python Client Methods:**
-
-```python
-from balatrobot import BalatroClient
-
-with BalatroClient() as client:
- # Save current game state as checkpoint
- client.save_checkpoint("tests/fixtures/my_state.jkr")
-
- # Load a checkpoint for testing
- save_path = client.prepare_save("tests/fixtures/my_state.jkr")
- game_state = client.load_save(save_path)
-```
-
-**Manual Setup for Advanced Testing:**
-
-```bash
-# Check/manage Balatro instances
-./balatro.sh --status # Show running instances
-./balatro.sh --kill # Kill all instances
-
-# Start instances manually
-./balatro.sh -p 12346 -p 12347 # Two instances
-./balatro.sh --headless --fast -p 12346 -p 12347 -p 12348 -p 12349 # Full setup
-./balatro.sh --audio -p 12346 # With audio enabled
+## Code Structure
-# Manual parallel testing
-pytest -n 4 --port 12346 --port 12347 --port 12348 --port 12349 tests/lua/
```
-
-**Performance Modes:**
-
-- **`--headless`**: No graphics, ideal for servers
-- **`--fast`**: 10x speed, disabled effects, optimal for testing
-- **`--audio`**: Enable audio (disabled by default for performance)
-
-### Documentation
-
-```bash
-make docs-serve # Serve documentation locally
-make docs-build # Build documentation
-make docs-clean # Clean built documentation
+src/lua/
+├── core/
+│ ├── server.lua # HTTP server
+│ ├── dispatcher.lua # Request routing
+│ └── validator.lua # Schema validation
+├── endpoints/ # API endpoints
+│ ├── health.lua
+│ ├── gamestate.lua
+│ ├── play.lua
+│ └── ...
+└── utils/
+ ├── types.lua # Type definitions
+ ├── enums.lua # Enum values
+ ├── errors.lua # Error codes
+ ├── gamestate.lua # State extraction
+ └── openrpc.json # API spec
```
-### Build & Maintenance
-
-```bash
-make build # Build package for distribution
-make clean # Clean build artifacts and caches
+## Adding a New Endpoint
+
+- Create `src/lua/endpoints/your_endpoint.lua`:
+
+```lua
+return {
+ name = "your_endpoint",
+ description = "Brief description",
+ schema = {
+ param_name = {
+ type = "string",
+ required = true,
+ description = "Parameter description",
+ },
+ },
+ requires_state = { G.STATES.SHOP }, -- Optional
+ execute = function(args, send_response)
+ -- Implementation
+ send_response(BB_GAMESTATE.get_gamestate())
+ end,
+}
```
-## Technical Guidelines
-
-### Python Development
-
-- **Style**: Follow modern Python 3.13+ patterns
-- **Type Hints**: Use pipe operator for unions (`str | int | None`)
-- **Type Aliases**: Use `type` statement
-- **Docstrings**: Google-style without type information (types in annotations)
-- **Generics**: Modern syntax (`class Container[T]:`)
-
-### Lua Development
-
-- **Focus Area**: Primary development is on `src/lua/api.lua`
-- **Communication**: TCP protocol on port 12346
-- **Debugging**: Use DebugPlus mod for enhanced debugging capabilities
-
-### Environment Variables
-
-Configure BalatroBot behavior with these environment variables:
+- Add tests in `tests/lua/endpoints/test_your_endpoint.py`
-- **`BALATROBOT_HEADLESS=1`**: Disable graphics for server environments
-- **`BALATROBOT_FAST=1`**: Enable 10x speed with disabled effects for testing
-- **`BALATROBOT_AUDIO=1`**: Enable audio (disabled by default for performance)
-- **`BALATROBOT_PORT`**: TCP communication port (default: "12346")
+> When writing tests for new endpoints, you can use the `@pytest.mark.dev` decorator to only run the tests you are developing with `make test PYTEST_MARKER=dev`.
-## Communication & Community
+- Update `src/lua/utils/openrpc.json` with the new method
-### Preferred Channels
+- Update `docs/api.md` with the new method
-- **GitHub Issues**: Primary communication for bugs, features, and project coordination
-- **Discord**: Join us at the [Balatro Discord](https://discord.com/channels/1116389027176787968/1391371948629426316) for real-time discussions
+## Pull Request Guidelines
-Happy contributing!
+1. **One feature per PR** - Keep changes focused
+2. **Add tests** - New endpoints need test coverage
+3. **Update docs** - Update api.md and openrpc.json for API changes
+4. **Follow conventions** - Match existing code style
+5. **Test locally** - Ensure `make test` passes
diff --git a/docs/developing-bots.md b/docs/developing-bots.md
deleted file mode 100644
index 3c863c7..0000000
--- a/docs/developing-bots.md
+++ /dev/null
@@ -1,147 +0,0 @@
-# Developing Bots
-
-BalatroBot allows you to create automated players (bots) that can play Balatro by implementing decision-making logic in Python. Your bot communicates with the game through a TCP socket connection, sending actions to perform and receiving back the game state.
-
-## Bot Architecture
-
-A bot is a finite state machine that implements a sequence of actions to play the game.
-The bot can be in one state at a time and has access to a set of functions that can move the bot to other states.
-
-| **State** | **Description** | **Functions** |
-| ---------------- | -------------------------------------------- | ---------------------------------------- |
-| `MENU` | The main menu | `start_run` |
-| `BLIND_SELECT` | Selecting or skipping the blind | `skip_or_select_blind` |
-| `SELECTING_HAND` | Selecting cards to play or discard | `play_hand_or_discard`, `rearrange_hand` |
-| `ROUND_EVAL` | Evaluating the round outcome and cashing out | `cash_out` |
-| `SHOP` | Buy items and move to the next round | `shop` |
-| `GAME_OVER` | Game has ended | – |
-
-Developing a bot boils down to providing the action name and its parameters for each state.
-
-### State Diagram
-
-The following diagram illustrates the possible states of the game and how the functions can be used to move the bot between them:
-
-- Start (◉) and End (⦾) states
-- States are written in uppercase (e.g., `MENU`, `BLIND_SELECT`, ...)
-- Functions are written in lowercase (e.g., `start_run`, `skip_or_select_blind`, ...)
-- Function parameters are written in italics (e.g., `action = play_hand`). Not all parameters are reported in the diagram.
-- Comments are reported in parentheses (e.g., `(win round)`, `(lose round)`).
-- Abstract groups are written with capital letters (e.g., `Run`, `Round`, ...)
-
-
-
-```mermaid
-stateDiagram-v2
- direction TB
-
- BLIND_SELECT_1:BLIND_SELECT
-
- [*] --> MENU: go_to_menu
- MENU --> BLIND_SELECT: start_run
-
- state Run{
-
- BLIND_SELECT --> skip_or_select_blind
-
- skip_or_select_blind --> BLIND_SELECT: *action = skip*(small or big blind)
- skip_or_select_blind --> SELECTING_HAND: *action = select*
-
- state Round {
- SELECTING_HAND --> play_hand_or_discard
-
- play_hand_or_discard --> SELECTING_HAND: *action = play_hand*
- play_hand_or_discard --> SELECTING_HAND: *action = discard*
- play_hand_or_discard --> ROUND_EVAL: *action = play_hand* (win round)
- play_hand_or_discard --> GAME_OVER: *action = play_hand* (lose round)
- }
-
- state RoundEval {
- ROUND_EVAL --> SHOP: *cash_out*
- }
-
- state Shop {
- SHOP --> shop
- shop --> BLIND_SELECT_1: *action = next_round*
- }
-
- state GameOver {
- GAME_OVER --> [*]
- }
-
- }
-
- state skip_or_select_blind <>
- state play_hand_or_discard <>
- state shop <>
-```
-
-
-
-## Development Environment Setup
-
-The BalatroBot project provides a complete development environment with all necessary tools and resources for developing bots.
-
-### Environment Setup
-
-Before developing or running bots, you need to set up the development environment by configuring the `.envrc` file:
-
-=== "Windows"
-
- ```sh
- cd %AppData%/Balatro/Mods/balatrobot
- copy .envrc.example .envrc
- .envrc
- ```
-
-=== "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot"
- cp .envrc.example .envrc
- source .envrc
- ```
-
-=== "Linux"
-
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot
- cp .envrc.example .envrc
- source .envrc
- ```
-
-!!! warning "Always Source Environment"
-
- Remember to source the `.envrc` file every time you start a new terminal
- session before developing or running bots. The environment variables are
- essential for proper bot functionality.
-
-!!! tip "Automatic Environment Loading with direnv"
-
- For a better development experience, consider using
- [direnv](https://direnv.net/) to automatically load and unload environment
- variables when entering and leaving the project directory.
-
- After installing direnv and hooking it into your shell:
-
- ```sh
- # Allow direnv to load the .envrc file automatically
- direnv allow .
- ```
-
- This eliminates the need to manually source `.envrc` every time you work on
- the project.
-
-### Bot File Location
-
-When developing new bots, place your files in the `bots/` directory using one of these recommended patterns:
-
-- **Single file bots**: `bots/my_new_bot.py`
-- **Complex bots**: `bots/my_new_bot/main.py` (for bots with multiple modules)
-
-## Next Steps
-
-After setting up your development environment:
-
-- Explore the [BalatroBot API](balatrobot-api.md) for detailed client and model documentation
-- Learn about the underlying [Protocol API](protocol-api.md) for TCP communication details
diff --git a/docs/index.md b/docs/index.md
index df2230e..7e9704d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,11 +1,29 @@
-
- { width="256" }
- A framework for developing Balatro bots
-
+
---
-BalatroBot is a Python framework designed to help developers create automated bots for the card game Balatro. The framework provides a comprehensive API for interacting with the game, handling game state, making strategic decisions, and executing actions. Whether you're building a simple bot or a sophisticated AI player, BalatroBot offers the tools and structure needed to get started quickly.
+BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing game state and controls for external program interaction. The API provides endpoints for complete game control, including card selection, shop transactions, blind selection, and state management. External clients connect via HTTP POST to execute game actions programmatically.
+
+!!! warning "Breaking Changes"
+
+ **BalatroBot 1.0.0 introduces breaking changes:**
+
+ - No longer a Python package (no PyPI releases)
+ - New JSON-RPC 2.0 protocol over HTTP/1.1
+ - Updated endpoints and API structure
+ - Removed game state logging functionality
+
+ BalatroBot is now a Lua mod that exposes an API for programmatic game control.
@@ -13,32 +31,64 @@ BalatroBot is a Python framework designed to help developers create automated bo
---
- Setup guide covering prerequisites, Steamodded mod installation, and Python environment setup.
+ Setup guide covering prerequisites and BalatroBot installation.
[:octicons-arrow-right-24: Installation](installation.md)
-- :material-robot:{ .lg .middle } __Developing Bots__
+- :material-robot:{ .lg .middle } __BalatroBot API__
---
- Learn to develop bots with complete code examples, class structure, and game state handling.
+ Message formats, game states, methods, schema, enums and errors
- [:octicons-arrow-right-24: Developing Bots](developing-bots.md)
+ [:octicons-arrow-right-24: API Reference](api.md)
-- :material-api:{ .lg .middle } __Protocol API__
+- :material-code-tags:{ .lg .middle } __Contributing__
---
- Technical reference for TCP socket communication, message formats, game states, and action types.
+ Setup guide for developers, test suite, and contributing guidelines.
- [:octicons-arrow-right-24: Protocol API](protocol-api.md)
+ [:octicons-arrow-right-24: Contributing](contributing.md)
- :octicons-sparkle-fill-16:{ .lg .middle } __Documentation for LLM__
---
- Documentation in [llms.txt](https://llmstxt.org/) format. Just paste the following link (or its content) into the LLM chat.
+ Docs in [llms.txt](https://llmstxt.org/) format. Paste the following link (or its content) into the LLM.
[:octicons-arrow-right-24: llms-full.txt](llms-full.txt)
+
+## Related Projects
+
+
diff --git a/docs/installation.md b/docs/installation.md
index ae54525..a2747fb 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,262 +1,122 @@
-# Installation Guide
+# Installation
-This guide will walk you through installing and setting up BalatroBot.
+This guide covers installing the BalatroBot mod for Balatro.
## Prerequisites
-Before installing BalatroBot, ensure you have:
+1. **Balatro** (v1.0.1+) - Purchase from [Steam](https://store.steampowered.com/app/2379780/Balatro/)
+2. **Lovely Injector** - Follow the [installation guide](https://github.com/ethangreen-dev/lovely-injector#manual-installation)
+3. **Steamodded** - Follow the [installation guide](https://github.com/Steamodded/smods/wiki)
-- **[balatro](https://store.steampowered.com/app/2379780/Balatro/)**: Steam version (>= 1.0.1)
-- **[git](https://git-scm.com/downloads)**: for cloning the repository
-- **[uv](https://docs.astral.sh/uv/)**: for managing Python installations, environments, and dependencies
-- **[lovely](https://github.com/ethangreen-dev/lovely-injector)**: for injecting Lua code into Balatro (>= 0.8.0)
-- **[steamodded](https://github.com/Steamodded/smods)**: for loading and injecting mods (>= 1.0.0)
+## Mod Installation
-## Step 1: Install BalatroBot
+### 1. Download BalatroBot
-BalatroBot is installed like any other Steamodded mod.
+Download the latest release from the [releases page](https://github.com/your-repo/balatrobot/releases) or clone the repository.
-=== "Windows"
+### 2. Copy to Mods Folder
- ```sh
- cd %AppData%/Balatro
- mkdir -p Mods
- cd Mods
- git clone https://github.com/coder/balatrobot.git
- ```
+Copy the following files/folders to your Balatro Mods directory:
-=== "MacOS"
+```
+balatrobot/
+├── balatrobot.json # Mod manifest
+├── balatrobot.lua # Entry point
+└── src/lua/ # API source code
+```
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro"
- mkdir -p Mods
- cd Mods
- git clone https://github.com/coder/balatrobot.git
- ```
+**Mods directory location:**
-=== "Linux"
+| Platform | Path |
+| -------- | ------------------------------------------------------------------------------------------------------------- |
+| Windows | `%AppData%/Balatro/Mods/balatrobot/` |
+| macOS | `~/Library/Application Support/Balatro/Mods/balatrobot/` |
+| Linux | `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/` |
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro
- mkdir -p Mods
- cd Mods
- git clone https://github.com/coder/balatrobot.git
- ```
+### 3. Launch Balatro
-!!! tip
+Use the platform-specific launcher script from the `scripts/` directory:
- You can also clone the repository somewhere else and then provide a symlink
- to the `balatrobot` directory in the `Mods` directory.
+```bash
+# macOS
+python scripts/balatro-macos.py --fast --debug
- === "Windows"
+# Linux (via Proton)
+python scripts/balatro-linux.py --fast --debug
- ```sh
- # Clone repository to a custom location
- cd C:\your\custom\path
- git clone https://github.com/coder/balatrobot.git
+# Windows
+python scripts/balatro-windows.py --fast --debug
+```
- # Create symlink in Mods directory
- cd %AppData%/Balatro/Mods
- mklink /D balatrobot C:\your\custom\path\balatrobot
- ```
+**Available options:**
- === "MacOS"
+| Flag | Description |
+| ----------------- | ------------------------------------------ |
+| `--host HOST` | Server hostname (default: 127.0.0.1) |
+| `--port PORT` | Server port (default: 12346) |
+| `--fast` | Fast mode (skip animations) |
+| `--headless` | Headless mode (no window) |
+| `--render-on-api` | Render only on API calls |
+| `--audio` | Enable audio (disabled by default) |
+| `--debug` | Debug mode (requires DebugPlus mod) |
+| `--no-shaders` | Disable all shaders for better performance |
- ```sh
- # Clone repository to a custom location
- cd /your/custom/path
- git clone https://github.com/coder/balatrobot.git
+The scripts automatically:
- # Create symlink in Mods directory
- cd "/Users/$USER/Library/Application Support/Balatro/Mods"
- ln -s /your/custom/path/balatrobot balatrobot
- ```
+- Kill any existing Balatro instances
+- Kill processes using the specified port
+- Set up the correct environment variables
+- Log output to `logs/balatro_{port}.log`
- === "Linux"
+### 4. Verify Installation
- ```sh
- # Clone repository to a custom location
- cd /your/custom/path
- git clone https://github.com/coder/balatrobot.git
+Start Balatro, then test the connection:
- # Create symlink in Mods directory
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods
- ln -s /your/custom/path/balatrobot balatrobot
- ```
+```bash
+curl -X POST http://127.0.0.1:12346 \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc": "2.0", "method": "health", "id": 1}'
+```
-??? "Update BalatroBot"
+Expected response:
- Updating BalatroBot is as simple as pulling the latest changes from the repository.
+```json
+{"jsonrpc":"2.0","result":{"status":"ok"},"id":1}
+```
- === "Windows"
-
- ```sh
- cd %AppData%/Balatro/Mods/balatrobot
- git pull
- ```
-
- === "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot"
- git pull
- ```
-
- === "Linux"
-
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot
- git pull
- ```
-
-??? "Uninstall BalatroBot"
-
- Simply delete the balatrobot mod directory.
-
- === "Windows"
-
- ```sh
- cd %AppData%/Balatro/Mods
- rmdir /S /Q balatrobot
- ```
-
- === "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro/Mods"
- rm -rf balatrobot
- ```
-
- === "Linux"
-
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods
- rm -rf balatrobot
- ```
-
-## Step 2: Set Up Python Environment
-
-Uv takes care of managing Python installations, virtual environment creation, and dependency installation.
-To set up the Python environment for running BalatroBot bots, simply run:
-
-=== "Windows"
-
- ```sh
- cd %AppData%/Balatro/Mods/balatrobot
- uv sync
- ```
-
-=== "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot"
- uv sync
- ```
-
-=== "Linux"
-
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot
- uv sync
- ```
-
-The same command can be used to update the Python environment and dependencies in the future.
-
-??? "Remove Python Environment"
-
- To uninstall the Python environment and dependencies, simply remove the `.venv` directory.
-
- === "Windows"
-
- ```sh
- cd %AppData%/Balatro/Mods/balatrobot
- rmdir /S /Q .venv
- ```
-
- === "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot"
- rm -rf .venv
- ```
-
- === "Linux"
-
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot
- rm -rf .venv
- ```
-
-## Step 3: Test Installation
-
-### Launch Balatro with Mods
-
-1. Start Balatro through Steam
-2. In the main menu, click "Mods"
-3. Verify "BalatroBot" appears in the mod list
-4. Enable the mod if it's not already enabled and restart the game
-
-!!! warning "macOS Steam Client Issue"
-
- On macOS, you cannot start Balatro through the Steam App due to a bug in the
- Steam client. Instead, you must use the `run_lovely_macos.sh` script.
-
- === "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Steam/steamapps/common/Balatro"
- ./run_lovely_macos.sh
- ```
-
- **First-time setup:** If this is your first time running the script, macOS Security & Privacy
- settings will prevent it from executing. Open **System Preferences** → **Security & Privacy**
- and click "Allow" when prompted, then run the script again.
-
-### Quick Test with Example Bot
-
-With Balatro running and the mod enabled, you can quickly test if everything is set up correctly using the provided example bot.
-
-=== "Windows"
-
- ```sh
- cd %AppData%/Balatro/Mods/balatrobot
- uv run bots/example.py
- ```
-
-=== "MacOS"
-
- ```sh
- cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot"
- uv run bots/example.py
- ```
-
-=== "Linux"
-
- ```sh
- cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot
- uv run bots/example.py
- ```
-
-!!! tip
+## Troubleshooting
- You can also navigate to the `balatrobot` directory, activate the Python
- environment and run the bot with `python bots/example.py` if you prefer.
- However, remember to always activate the virtual environment first.
+- **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**: Change `BALATROBOT_PORT` to a different value
-The bot is working correctly if:
+## Custom Launchers
-1. Game starts automatically
-2. Cards are played/discarded automatically
-3. Win the first blind
-4. Game progresses through blinds
+If you're using a custom launcher or need to start Balatro manually, set these environment variables before launching:
-## Troubleshooting
+| Variable | Default | Description |
+| -------------------------- | ----------- | ------------------------------------------ |
+| `BALATROBOT_HOST` | `127.0.0.1` | Server hostname |
+| `BALATROBOT_PORT` | `12346` | Server port |
+| `BALATROBOT_FAST` | `0` | Fast mode (1=enabled) |
+| `BALATROBOT_HEADLESS` | `0` | Headless mode (1=enabled) |
+| `BALATROBOT_RENDER_ON_API` | `0` | Render only on API calls (1=enabled) |
+| `BALATROBOT_AUDIO` | `0` | Audio (1=enabled) |
+| `BALATROBOT_DEBUG` | `0` | Debug mode (1=enabled, requires DebugPlus) |
+| `BALATROBOT_NO_SHADERS` | `0` | Disable all shaders (1=enabled) |
-If you encounter issues during installation or testing:
+**Example (bash):**
-- **Discord Support**: Join our community at [https://discord.gg/xzBAj4JFVC](https://discord.gg/xzBAj4JFVC) for real-time help
-- **GitHub Issues**: Report bugs or request features by [opening an issue](https://github.com/coder/balatrobot/issues) on GitHub
+```bash
+export BALATROBOT_PORT=12346
+export BALATROBOT_FAST=1
+# Then launch Balatro with the Lovely Injector
+```
----
+**Example (Windows PowerShell):**
-*Once installation is complete, proceed to the [Developing Bots](developing-bots.md) to create your first bot!*
+```powershell
+$env:BALATROBOT_PORT = "12346"
+$env:BALATROBOT_FAST = "1"
+# Then launch Balatro.exe
+```
diff --git a/docs/logging-systems.md b/docs/logging-systems.md
deleted file mode 100644
index 5576cbb..0000000
--- a/docs/logging-systems.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Logging Systems
-
-BalatroBot implements three distinct logging systems to support different aspects of development, debugging, and analysis:
-
-1. [**JSONL Run Logging**](#jsonl-run-logging) - Records complete game runs for replay and analysis
-2. [**Python SDK Logging**](#python-sdk-logging) - Future logging capabilities for the Python framework
-3. [**Mod Logging**](#mod-logging) - Traditional Steamodded logging for mod development and debugging
-
-## JSONL Run Logging
-
-The run logging system records complete game runs as JSONL (JSON Lines) files. Each line represents a single game action with its parameters, timestamp, and game state **before** the action.
-
-The system hooks into these game functions:
-
-- `start_run`: begins a new game run
-- `skip_or_select_blind`: blind selection actions
-- `play_hand_or_discard`: card play actions
-- `cash_out`: end blind and collect rewards
-- `shop`: shop interactions (`next_round`, `buy_card`, `reroll`)
-- `go_to_menu`: return to main menu
-
-The JSONL files are automatically created when:
-
-- **Playing manually**: Starting a new run through the game interface
-- **Using the API**: Interacting with the game through the TCP API
-
-Files are saved as: `{mod_path}/runs/YYYYMMDDTHHMMSS.jsonl`
-
-!!! tip "Replay runs"
-
- The JSONL logs enable complete run replay for testing and analysis.
-
- ```python
- state = load_jsonl_run("20250714T145700.jsonl")
- for step in state:
- send_and_receive_api_message(
- tcp_client,
- step["function"]["name"],
- step["function"]["arguments"]
- )
- ```
-
-Examples for runs can be found in the [test suite](https://github.com/coder/balatrobot/tree/main/tests/runs).
-
-### Format Specification
-
-Each log entry follows this structure:
-
-```json
-{
- "timestamp_ms": int,
- "function": {
- "name": "...",
- "arguments": {...}
- },
- "game_state": { ... }
-}
-```
-
-- **`timestamp_ms`**: Unix timestamp in milliseconds when the action occurred
-- **`function`**: The game function that was called
- - `name`: Function name (e.g., "start_run", "play_hand_or_discard", "cash_out")
- - `arguments`: Arguments passed to the function
-- **`game_state`**: Complete game state **before** the function execution
-
-## Python SDK Logging
-
-The Python SDK (`src/balatrobot/`) implements structured logging for bot development and debugging. The logging system provides visibility into client operations, API communications, and error handling.
-
-### What Gets Logged
-
-The `BalatroClient` logs the following operations:
-
-- **Connection events**: When connecting to and disconnecting from the game API
-- **API requests**: Function names being called and their completion status
-- **Errors**: Connection failures, socket errors, and invalid API responses
-
-### Configuration Example
-
-The SDK uses Python's built-in `logging` module. Configure it in your bot code before using the client:
-
-```python
-import logging
-from balatrobot import BalatroClient
-
-# Configure logging
-log_format = '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
-console_handler = logging.StreamHandler()
-console_handler.setLevel(logging.INFO)
-file_handler = logging.FileHandler('balatrobot.log')
-file_handler.setLevel(logging.DEBUG)
-
-logging.basicConfig(
- level=logging.DEBUG,
- format=log_format,
- handlers=[console_handler, file_handler]
-)
-
-# Use the client
-with BalatroClient() as client:
- state = client.get_game_state()
- client.start_run(deck="Red Deck", stake=1)
-```
-
-## Mod Logging
-
-BalatroBot uses Steamodded's built-in logging system for mod development and debugging.
-
-- **Traditional logging**: Standard log levels (DEBUG, INFO, WARNING, ERROR)
-- **Development focus**: Primarily for debugging mod functionality
-- **Console output**: Displays in game console and log files
-
-```lua
--- Available through Steamodded
-sendDebugMessage("This is a debug message")
-sendInfoMessage("This is an info message")
-sendWarningMessage("This is a warning message")
-sendErrorMessage("This is an error message")
-```
diff --git a/docs/protocol-api.md b/docs/protocol-api.md
deleted file mode 100644
index 7e99cce..0000000
--- a/docs/protocol-api.md
+++ /dev/null
@@ -1,230 +0,0 @@
-# Protocol API
-
-This document provides the TCP API protocol reference for developers who want to interact directly with the BalatroBot game interface using raw socket connections.
-
-## Protocol
-
-The BalatroBot API establishes a TCP socket connection to communicate with the Balatro game through the BalatroBot Lua mod. The protocol uses a simple JSON request-response model for synchronous communication.
-
-- **Host:** `127.0.0.1` (default, configurable via `BALATROBOT_HOST`)
-- **Port:** `12346` (default, configurable via `BALATROBOT_PORT`)
-- **Message Format:** JSON
-
-### Configuration
-
-The API server can be configured using environment variables:
-
-- `BALATROBOT_HOST`: The network interface to bind to (default: `127.0.0.1`)
- - `127.0.0.1`: Localhost only (secure for local development)
- - `*` or `0.0.0.0`: All network interfaces (required for Docker or remote access)
-- `BALATROBOT_PORT`: The TCP port to listen on (default: `12346`)
-- `BALATROBOT_HEADLESS`: Enable headless mode (`1` to enable)
-- `BALATROBOT_FAST`: Enable fast mode for faster gameplay (`1` to enable)
-
-### Communication Sequence
-
-The typical interaction follows a game loop where clients continuously query the game state, analyze it, and send appropriate actions:
-
-```mermaid
-sequenceDiagram
- participant Client
- participant BalatroBot
-
- loop Game Loop
- Client->>BalatroBot: {"name": "get_game_state", "arguments": {}}
- BalatroBot->>Client: {game state JSON}
-
- Note over Client: Analyze game state and decide action
-
- Client->>BalatroBot: {"name": "function_name", "arguments": {...}}
-
- alt Valid Function Call
- BalatroBot->>Client: {updated game state}
- else Error
- BalatroBot->>Client: {"error": "description", ...}
- end
- end
-```
-
-### Message Format
-
-All communication uses JSON messages with a standardized structure. The protocol defines three main message types: function call requests, successful responses, and error responses.
-
-**Request Format:**
-
-```json
-{
- "name": "function_name",
- "arguments": {
- "param1": "value1",
- "param2": ["array", "values"]
- }
-}
-```
-
-**Response Format:**
-
-```json
-{
- "state": 7,
- "game": { ... },
- "hand": [ ... ],
- "jokers": [ ... ]
-}
-```
-
-**Error Response Format:**
-
-```json
-{
- "error": "Error message description",
- "error_code": "E001",
- "state": 7,
- "context": {
- "additional": "error details"
- }
-}
-```
-
-## Game States
-
-The BalatroBot API operates as a finite state machine that mirrors the natural flow of playing Balatro. Each state represents a distinct phase where specific actions are available.
-
-### Overview
-
-The game progresses through these states in a typical flow: `MENU` → `BLIND_SELECT` → `SELECTING_HAND` → `ROUND_EVAL` → `SHOP` → `BLIND_SELECT` (or `GAME_OVER`).
-
-| State | Value | Description | Available Functions |
-| ---------------- | ----- | ---------------------------- | ------------------------------------------------------------------------------------------- |
-| `MENU` | 11 | Main menu screen | `start_run` |
-| `BLIND_SELECT` | 7 | Selecting or skipping blinds | `skip_or_select_blind`, `sell_joker`, `sell_consumable`, `use_consumable` |
-| `SELECTING_HAND` | 1 | Playing or discarding cards | `play_hand_or_discard`, `rearrange_hand`, `sell_joker`, `sell_consumable`, `use_consumable` |
-| `ROUND_EVAL` | 8 | Round completion evaluation | `cash_out`, `sell_joker`, `sell_consumable`, `use_consumable` |
-| `SHOP` | 5 | Shop interface | `shop`, `sell_joker`, `sell_consumable`, `use_consumable` |
-| `GAME_OVER` | 4 | Game ended | `go_to_menu` |
-
-### Validation
-
-Functions can only be called when the game is in their corresponding valid states. The `get_game_state` function is available in all states.
-
-!!! tip "Game State Reset"
-
- The `go_to_menu` function can be used in any state to reset a run. However,
- run resuming is not supported by BalatroBot. So performing a `go_to_menu` is
- effectively equivalent to resetting the run. This can be used to restart the
- game to a clean state.
-
-## Game Functions
-
-The BalatroBot API provides core functions that correspond to the main game actions. Each function is state-dependent and can only be called in the appropriate game state.
-
-### Overview
-
-| Name | Description |
-| \----------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
-| `get_game_state` | Retrieves the current complete game state |
-| `go_to_menu` | Returns to the main menu from any game state |
-| `start_run` | Starts a new game run with specified configuration |
-| `skip_or_select_blind` | Handles blind selection - either select the current blind to play or skip it |
-| `play_hand_or_discard` | Plays selected cards or discards them |
-| `rearrange_hand` | Reorders the current hand according to the supplied index list |
-| `rearrange_consumables` | Reorders the consumables according to the supplied index list |
-| `cash_out` | Proceeds from round completion to the shop phase |
-| `shop` | Performs shop actions: proceed to next round (`next_round`), purchase (and use) a card (`buy_card` | `buy_and_use_card`), or reroll shop (`reroll`) |
-| `sell_joker` | Sells a joker from the player's collection for money |
-| `sell_consumable` | Sells a consumable from the player's collection for money |
-| `use_consumable` | Uses a consumable card from the player's collection (Tarot, Planet, or Spectral cards) |
-
-### Parameters
-
-The following table details the parameters required for each function. Note that `get_game_state` and `go_to_menu` require no parameters:
-
-| Name | Parameters |
-| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `start_run` | `deck` (string): Deck name `stake` (number): Difficulty level 1-8 `seed` (string, optional): Seed for run generation `challenge` (string, optional): Challenge name `log_path` (string, optional): Full file path for run log (must include .jsonl extension) |
-| `skip_or_select_blind` | `action` (string): Either "select" or "skip" |
-| `play_hand_or_discard` | `action` (string): Either "play_hand" or "discard" `cards` (array): Card indices (0-indexed, 1-5 cards) |
-| `rearrange_hand` | `cards` (array): Card indices (0-indexed, exactly `hand_size` elements) |
-| `rearrange_consumables` | `consumables` (array): Consumable indices (0-indexed, exactly number of consumables in consumable area) |
-| `shop` | `action` (string): Shop action ("next_round", "buy_card", "buy_and_use_card", "reroll", or "redeem_voucher") `index` (number, required when `action` is one of "buy_card", "redeem_voucher", "buy_and_use_card"): 0-based card index to purchase / redeem |
-| `sell_joker` | `index` (number): 0-based index of the joker to sell from the player's joker collection |
-| `sell_consumable` | `index` (number): 0-based index of the consumable to sell from the player's consumable collection |
-| `use_consumable` | `index` (number): 0-based index of the consumable to use from the player's consumable collection |
-
-### Shop Actions
-
-The `shop` function supports multiple in-shop actions. Use the `action` field inside the `arguments` object to specify which of these to execute.
-
-| Action | Description | Additional Parameters |
-| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
-| `next_round` | Leave the shop and proceed to the next blind selection. | — |
-| `buy_card` | Purchase the card at the supplied `index` in `shop_jokers`. | `index` _(number)_ – 0-based position of the card to buy |
-| `buy_and_use_card` | Purchase and use the card at the supplied `index` in `shop_jokers`; only some consumables may be bought and used. | `index` _(number)_ – 0-based position of the card to buy |
-| `reroll` | Spend dollars to refresh the shop offer (cost shown in-game). | — |
-| `redeem_voucher` | Redeem the voucher at the supplied `index` in `shop_vouchers`, applying its discount or effect. | `index` _(number)_ – 0-based position of the voucher to redeem |
-
-!!! note "Future actions"
-
-Additional shop actions such as `buy_and_use_card` and `open_pack` are planned.
-
-### Development Tools
-
-These endpoints are primarily for development, testing, and debugging purposes:
-
-#### `get_save_info`
-
-Returns information about the current save file location and profile.
-
-**Arguments:** None
-
-**Returns:**
-
-- `profile_path` _(string)_ – Current profile path (e.g., "3")
-- `save_directory` _(string)_ – Full path to Love2D save directory
-- `save_file_path` _(string)_ – Full OS-specific path to save.jkr file
-- `has_active_run` _(boolean)_ – Whether a run is currently active
-- `save_exists` _(boolean)_ – Whether a save file exists
-
-#### `load_save`
-
-Loads a save file directly without requiring a game restart. This is useful for testing specific game states.
-
-**Arguments:**
-
-- `save_path` _(string)_ – Path to the save file relative to Love2D save directory (e.g., "3/save.jkr")
-
-**Returns:** Game state after loading the save
-
-!!! warning "Development Use"
-
- These endpoints are intended for development and testing. The `load_save` function bypasses normal game flow and should be used carefully.
-
-### Errors
-
-All API functions validate their inputs and game state before execution. Error responses include an `error` message, standardized `error_code`, current `state` value, and optional `context` with additional details.
-
-| Code | Category | Error |
-| ------ | ---------- | ------------------------------------------ |
-| `E001` | Protocol | Invalid JSON in request |
-| `E002` | Protocol | Message missing required 'name' field |
-| `E003` | Protocol | Message missing required 'arguments' field |
-| `E004` | Protocol | Unknown function name |
-| `E005` | Protocol | Arguments must be a table |
-| `E006` | Network | Socket creation failed |
-| `E007` | Network | Socket bind failed |
-| `E008` | Network | Connection failed |
-| `E009` | Validation | Invalid game state for requested action |
-| `E010` | Validation | Invalid or missing required parameter |
-| `E011` | Validation | Parameter value out of valid range |
-| `E012` | Validation | Required game object missing |
-| `E013` | Game Logic | Deck not found |
-| `E014` | Game Logic | Invalid card index |
-| `E015` | Game Logic | No discards remaining |
-| `E016` | Game Logic | Invalid action for current context |
-
-## Implementation
-
-For higher-level integration:
-
-- Use the [BalatroBot API](balatrobot-api.md) `BalatroClient` for managed connections
-- See [Developing Bots](developing-bots.md) for complete bot implementation examples
diff --git a/mkdocs.yml b/mkdocs.yml
index 1e2788b..65c9388 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,5 +1,5 @@
site_name: BalatroBot
-site_description: A bot framework for Balatro
+site_description: API for developing Balatro bots
site_author: 'S1M0N38'
repo_name: 'coder/balatrobot'
repo_url: https://github.com/coder/balatrobot
@@ -7,6 +7,8 @@ site_url: https://coder.github.io/balatrobot/
docs_dir: docs/
theme:
name: material
+ favicon: assets/balatrobot.svg
+ logo: assets/balatrobot.svg
icon:
repo: fontawesome/brands/github
features:
@@ -33,36 +35,18 @@ plugins:
- search
- llmstxt:
markdown_description: |
- BalatroBot is a Python framework for developing automated bots to play the card game Balatro.
- The architecture consists of three main layers: a communication layer using TCP protocol with Lua API,
- a Python framework layer for bot development, and comprehensive testing and documentation systems.
- The project enables real-time bidirectional communication between the game and bot through TCP sockets.
+ BalatroBot is a mod for Balatro that serves a JSON-RPC 2.0 HTTP API, exposing game state and controls for external program interaction. The API provides endpoints for complete game control, including card selection, shop transactions, blind selection, and state management. External clients connect via HTTP POST to execute game actions programmatically.
sections:
Documentation:
- installation.md
- - developing-bots.md
- - balatrobot-api.md
- - protocol-api.md
+ - api.md
- contributing.md
full_output: llms-full.txt
autoclean: true
- - mkdocstrings:
- handlers:
- python:
- options:
- docstring_style: google
- show_root_heading: true
- show_source: false
- show_bases: false
- filters: ["!^_"]
- heading_level: 4
nav:
- - index.md
+ - BalatroBot: index.md
- Installation: installation.md
- - Developing Bots: developing-bots.md
- - BalatroBot API: balatrobot-api.md
- - Protocol API: protocol-api.md
- - Logging Systems: logging-systems.md
+ - API Reference: api.md
- Contributing: contributing.md
markdown_extensions:
- toc:
@@ -73,17 +57,12 @@ markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.highlight:
+ anchor_linenums: false
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.snippets
- - pymdownx.superfences:
- custom_fences:
- - name: mermaid
- class: mermaid
- format: !!python/name:pymdownx.superfences.fence_code_format
- - pymdownx.tabbed:
- alternate_style: true
+ - pymdownx.superfences
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
diff --git a/pyproject.toml b/pyproject.toml
index 8e2d84f..116f2e1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,23 +1,22 @@
[project]
name = "balatrobot"
version = "0.7.5"
-description = "A framework for Balatro bot development"
+description = "API for developing Balatro bots"
readme = "README.md"
authors = [
{ name = "S1M0N38", email = "bertolottosimone@gmail.com" },
- { name = "giewev", email = "giewev@gmail.com" },
+ { name = "stirby" },
+ { name = "giewev" },
{ name = "besteon" },
{ name = "phughesion" },
]
requires-python = ">=3.13"
-dependencies = ["pydantic>=2.11.7"]
+dependencies = []
classifiers = [
- "Development Status :: 1 - Planning",
"Framework :: Pytest",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.13",
- "Topic :: Software Development :: Libraries :: Python Modules",
]
[project.urls]
@@ -26,10 +25,6 @@ Issues = "https://github.com/coder/balatrobot/issues"
Repository = "https://github.com/coder/balatrobot"
Changelog = "https://github.com/coder/balatrobot/blob/main/CHANGELOG.md"
-[build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
-
[tool.ruff]
lint.extend-select = ["I"]
lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"]
@@ -38,19 +33,20 @@ lint.task-tags = ["FIX", "TODO", "HACK", "WARN", "PERF", "NOTE", "TEST"]
typeCheckingMode = "basic"
[tool.pytest.ini_options]
-addopts = "--cov=src/balatrobot --cov-report=term-missing --cov-report=html --cov-report=xml"
+markers = ["dev: marks tests that are currently developed"]
[dependency-groups]
dev = [
"basedpyright>=1.29.5",
- "deepdiff>=8.5.0",
+ "httpx>=0.28.1",
"mdformat-mkdocs>=4.3.0",
"mdformat-simple-breaks>=0.0.1",
"mkdocs-llmstxt>=0.3.0",
"mkdocs-material>=9.6.15",
- "mkdocstrings[python]>=0.29.1",
"pytest>=8.4.1",
"pytest-cov>=6.2.1",
+ "pytest-rerunfailures>=16.1",
"pytest-xdist[psutil]>=3.8.0",
"ruff>=0.12.2",
+ "tqdm>=4.67.0",
]
diff --git a/runs/.gitkeep b/runs/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/scripts/balatro-linux.py b/scripts/balatro-linux.py
new file mode 100755
index 0000000..fecf00f
--- /dev/null
+++ b/scripts/balatro-linux.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""Balatro launcher for Linux (via Proton/Steam Play)."""
+
+import argparse
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+# Balatro Steam App ID
+BALATRO_APP_ID = "2379780"
+
+# Steam paths to check (in order of preference)
+STEAM_PATHS = [
+ Path.home() / ".local/share/Steam",
+ Path.home() / ".steam/steam",
+ Path.home() / "snap/steam/common/.local/share/Steam",
+ Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
+]
+
+LOGS_DIR = Path("logs")
+
+
+def find_steam_path() -> Path | None:
+ """Find the Steam installation path."""
+ for path in STEAM_PATHS:
+ if (path / "steamapps/common/Balatro").exists():
+ return path
+ return None
+
+
+def find_proton(steam_path: Path) -> Path | None:
+ """Find a Proton installation."""
+ proton_dirs = [
+ steam_path / "steamapps/common/Proton - Experimental",
+ steam_path / "steamapps/common/Proton 9.0",
+ steam_path / "steamapps/common/Proton 8.0",
+ ]
+ # Also check for GE-Proton
+ compattools = steam_path / "compatibilitytools.d"
+ if compattools.exists():
+ for tool in sorted(compattools.iterdir(), reverse=True):
+ if tool.is_dir() and "proton" in tool.name.lower():
+ proton_dirs.insert(0, tool)
+
+ for proton_dir in proton_dirs:
+ proton_exe = proton_dir / "proton"
+ if proton_exe.exists():
+ return proton_dir
+ return None
+
+
+def wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:
+ """Wait for port to be ready to accept connections."""
+ import socket
+
+ start = time.time()
+ while time.time() - start < timeout:
+ try:
+ with socket.create_connection((host, port), timeout=1):
+ return True
+ except (ConnectionRefusedError, OSError):
+ time.sleep(0.5)
+ return False
+
+
+def kill_port(port: int):
+ """Kill processes using the specified port."""
+ print(f"Killing processes on port {port}...")
+ result = subprocess.run(
+ ["lsof", "-ti", f":{port}"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ if result.stdout.strip():
+ pids = result.stdout.strip().split("\n")
+ for pid in pids:
+ print(f" Killing PID {pid}")
+ subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL)
+ time.sleep(0.5)
+
+
+def kill_balatro():
+ """Kill all running Balatro instances."""
+ print("Killing existing Balatro instances...")
+ subprocess.run(["pkill", "-f", "Balatro"], stderr=subprocess.DEVNULL)
+ time.sleep(1)
+ subprocess.run(["pkill", "-9", "-f", "Balatro"], stderr=subprocess.DEVNULL)
+
+
+def start(args):
+ """Start Balatro with the given configuration."""
+ # Find Steam installation
+ steam_path = find_steam_path()
+ if not steam_path:
+ print("ERROR: Balatro not found in any Steam location.")
+ print("Checked paths:")
+ for p in STEAM_PATHS:
+ print(f" - {p}/steamapps/common/Balatro")
+ sys.exit(1)
+
+ game_dir = steam_path / "steamapps/common/Balatro"
+ balatro_exe = game_dir / "Balatro.exe"
+ version_dll = game_dir / "version.dll"
+ compat_data = steam_path / f"steamapps/compatdata/{BALATRO_APP_ID}"
+
+ print(f"Found Balatro at: {game_dir}")
+
+ if not balatro_exe.exists():
+ print(f"ERROR: Balatro.exe not found at {balatro_exe}")
+ sys.exit(1)
+
+ if not version_dll.exists():
+ print(f"ERROR: version.dll not found at {version_dll}")
+ print("Make sure the lovely injector is installed.")
+ sys.exit(1)
+
+ # Find Proton
+ proton_dir = find_proton(steam_path)
+ if not proton_dir:
+ print("ERROR: No Proton installation found.")
+ print("Install Proton via Steam or download GE-Proton.")
+ sys.exit(1)
+
+ proton_exe = proton_dir / "proton"
+ print(f"Using Proton: {proton_dir.name}")
+
+ # Kill existing processes
+ kill_port(args.port)
+ kill_balatro()
+
+ # Create logs directory
+ LOGS_DIR.mkdir(exist_ok=True)
+
+ # Set environment variables
+ env = os.environ.copy()
+
+ # Proton environment
+ env["STEAM_COMPAT_DATA_PATH"] = str(compat_data)
+ env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(steam_path)
+ env["WINEDLLOVERRIDES"] = "version=n,b"
+
+ # BalatroBot environment
+ env["BALATROBOT_HOST"] = args.host
+ env["BALATROBOT_PORT"] = str(args.port)
+
+ if args.headless:
+ env["BALATROBOT_HEADLESS"] = "1"
+ if args.fast:
+ env["BALATROBOT_FAST"] = "1"
+ if args.render_on_api:
+ env["BALATROBOT_RENDER_ON_API"] = "1"
+ if args.audio:
+ env["BALATROBOT_AUDIO"] = "1"
+ if args.debug:
+ env["BALATROBOT_DEBUG"] = "1"
+ if args.no_shaders:
+ env["BALATROBOT_NO_SHADERS"] = "1"
+
+ # Open log file and start Balatro via Proton
+ log_file = LOGS_DIR / f"balatro_{args.port}.log"
+ with open(log_file, "w") as log:
+ process = subprocess.Popen(
+ [str(proton_exe), "run", str(balatro_exe)],
+ env=env,
+ cwd=str(game_dir),
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ )
+
+ # Wait for port to be ready
+ print(f"Waiting for port {args.port} to be ready...")
+ if not wait_for_port(args.host, args.port, timeout=30):
+ print(f"ERROR: Port {args.port} not ready after 30s. Check {log_file}")
+ if process.poll() is not None:
+ print("Balatro process has exited.")
+ sys.exit(1)
+
+ print("Balatro started successfully!")
+ print(f" Port: {args.port}")
+ print(f" PID: {process.pid}")
+ print(f" Log: {log_file}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Balatro launcher for Linux")
+
+ parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ help="Server host (default: 127.0.0.1)",
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=12346,
+ help="Server port (default: 12346)",
+ )
+ parser.add_argument(
+ "--headless",
+ action="store_true",
+ help="Run in headless mode",
+ )
+ parser.add_argument(
+ "--fast",
+ action="store_true",
+ help="Run in fast mode",
+ )
+ parser.add_argument(
+ "--render-on-api",
+ action="store_true",
+ help="Render only on API calls",
+ )
+ parser.add_argument(
+ "--audio",
+ action="store_true",
+ help="Enable audio",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Enable debug mode",
+ )
+ parser.add_argument(
+ "--no-shaders",
+ action="store_true",
+ help="Disable all shaders",
+ )
+
+ args = parser.parse_args()
+ start(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/balatro-macos.py b/scripts/balatro-macos.py
new file mode 100755
index 0000000..9c4a8ca
--- /dev/null
+++ b/scripts/balatro-macos.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+"""Balatro launcher for macOS."""
+
+import argparse
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+# macOS-specific paths
+STEAM_PATH = Path.home() / "Library/Application Support/Steam/steamapps/common/Balatro"
+BALATRO_EXE = STEAM_PATH / "Balatro.app/Contents/MacOS/love"
+LOVELY_LIB = STEAM_PATH / "liblovely.dylib"
+LOGS_DIR = Path("logs")
+
+
+def wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:
+ """Wait for port to be ready to accept connections."""
+ import socket
+
+ start = time.time()
+ while time.time() - start < timeout:
+ try:
+ with socket.create_connection((host, port), timeout=1):
+ return True
+ except (ConnectionRefusedError, OSError):
+ time.sleep(0.5)
+ return False
+
+
+def kill_port(port: int):
+ """Kill processes using the specified port."""
+ print(f"Killing processes on port {port}...")
+ result = subprocess.run(
+ ["lsof", "-ti", f":{port}"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ if result.stdout.strip():
+ pids = result.stdout.strip().split("\n")
+ for pid in pids:
+ print(f" Killing PID {pid}")
+ subprocess.run(["kill", "-9", pid], stderr=subprocess.DEVNULL)
+ time.sleep(0.5)
+
+
+def kill_balatro():
+ """Kill all running Balatro instances."""
+ print("Killing existing Balatro instances...")
+ subprocess.run(["pkill", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL)
+ time.sleep(1)
+ # Force kill if still running
+ subprocess.run(["pkill", "-9", "-f", "Balatro\\.app"], stderr=subprocess.DEVNULL)
+
+
+def start(args):
+ """Start Balatro with the given configuration."""
+ # Verify paths exist
+ if not BALATRO_EXE.exists():
+ print(f"ERROR: Balatro not found at {BALATRO_EXE}")
+ print("Make sure Balatro is installed via Steam.")
+ sys.exit(1)
+
+ if not LOVELY_LIB.exists():
+ print(f"ERROR: liblovely.dylib not found at {LOVELY_LIB}")
+ print("Make sure the lovely injector is installed.")
+ sys.exit(1)
+
+ # Kill existing processes
+ kill_port(args.port)
+ kill_balatro()
+
+ # Create logs directory
+ LOGS_DIR.mkdir(exist_ok=True)
+
+ # Set environment variables
+ env = os.environ.copy()
+ env["DYLD_INSERT_LIBRARIES"] = str(LOVELY_LIB)
+ env["BALATROBOT_HOST"] = args.host
+ env["BALATROBOT_PORT"] = str(args.port)
+
+ if args.headless:
+ env["BALATROBOT_HEADLESS"] = "1"
+ if args.fast:
+ env["BALATROBOT_FAST"] = "1"
+ if args.render_on_api:
+ env["BALATROBOT_RENDER_ON_API"] = "1"
+ if args.audio:
+ env["BALATROBOT_AUDIO"] = "1"
+ if args.debug:
+ env["BALATROBOT_DEBUG"] = "1"
+ if args.no_shaders:
+ env["BALATROBOT_NO_SHADERS"] = "1"
+
+ # Open log file and start Balatro
+ log_file = LOGS_DIR / f"balatro_{args.port}.log"
+ with open(log_file, "w") as log:
+ process = subprocess.Popen(
+ [str(BALATRO_EXE)],
+ env=env,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ )
+
+ # Wait for port to be ready
+ print(f"Waiting for port {args.port} to be ready...")
+ if not wait_for_port(args.host, args.port, timeout=30):
+ print(f"ERROR: Port {args.port} not ready after 30s. Check {log_file}")
+ if process.poll() is not None:
+ print("Balatro process has exited.")
+ sys.exit(1)
+
+ print("Balatro started successfully!")
+ print(f" Port: {args.port}")
+ print(f" PID: {process.pid}")
+ print(f" Log: {log_file}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Balatro launcher for macOS")
+
+ parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ help="Server host (default: 127.0.0.1)",
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=12346,
+ help="Server port (default: 12346)",
+ )
+ parser.add_argument(
+ "--headless",
+ action="store_true",
+ help="Run in headless mode",
+ )
+ parser.add_argument(
+ "--fast",
+ action="store_true",
+ help="Run in fast mode",
+ )
+ parser.add_argument(
+ "--render-on-api",
+ action="store_true",
+ help="Render only on API calls",
+ )
+ parser.add_argument(
+ "--audio",
+ action="store_true",
+ help="Enable audio",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Enable debug mode",
+ )
+ parser.add_argument(
+ "--no-shaders",
+ action="store_true",
+ help="Disable all shaders",
+ )
+
+ args = parser.parse_args()
+ start(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/balatro-windows.py b/scripts/balatro-windows.py
new file mode 100755
index 0000000..29f8ad3
--- /dev/null
+++ b/scripts/balatro-windows.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""Balatro launcher for Windows."""
+
+import argparse
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+# Windows Steam paths to check
+STEAM_PATHS = [
+ Path("C:/Program Files (x86)/Steam/steamapps/common/Balatro"),
+ Path("C:/Program Files/Steam/steamapps/common/Balatro"),
+ Path.home() / "Steam/steamapps/common/Balatro",
+]
+
+LOGS_DIR = Path("logs")
+
+
+def wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:
+ """Wait for port to be ready to accept connections."""
+ import socket
+
+ start = time.time()
+ while time.time() - start < timeout:
+ try:
+ with socket.create_connection((host, port), timeout=1):
+ return True
+ except (ConnectionRefusedError, OSError):
+ time.sleep(0.5)
+ return False
+
+
+def find_game_path() -> Path | None:
+ """Find the Balatro installation path."""
+ for path in STEAM_PATHS:
+ if path.exists() and (path / "Balatro.exe").exists():
+ return path
+ return None
+
+
+def kill_port(port: int):
+ """Kill processes using the specified port."""
+ print(f"Killing processes on port {port}...")
+ try:
+ # Use netstat to find PIDs listening on the port
+ result = subprocess.run(
+ ["netstat", "-ano"],
+ capture_output=True,
+ text=True,
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+ pids = set()
+ for line in result.stdout.splitlines():
+ if f":{port}" in line and "LISTENING" in line:
+ parts = line.split()
+ if parts:
+ pid = parts[-1]
+ if pid.isdigit():
+ pids.add(pid)
+
+ for pid in pids:
+ print(f" Killing PID {pid}")
+ subprocess.run(
+ ["taskkill", "/F", "/PID", pid],
+ stderr=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+ if pids:
+ time.sleep(0.5)
+ except Exception as e:
+ print(f" Warning: Could not kill port processes: {e}")
+
+
+def kill_balatro():
+ """Kill all running Balatro instances."""
+ print("Killing existing Balatro instances...")
+ try:
+ subprocess.run(
+ ["taskkill", "/F", "/IM", "Balatro.exe"],
+ stderr=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+ time.sleep(1)
+ except Exception:
+ pass
+
+
+def start(args):
+ """Start Balatro with the given configuration."""
+ # Find game installation
+ game_dir = find_game_path()
+ if not game_dir:
+ print("ERROR: Balatro not found in any Steam location.")
+ print("Checked paths:")
+ for p in STEAM_PATHS:
+ print(f" - {p}")
+ sys.exit(1)
+
+ balatro_exe = game_dir / "Balatro.exe"
+ version_dll = game_dir / "version.dll"
+
+ print(f"Found Balatro at: {game_dir}")
+
+ if not version_dll.exists():
+ print(f"ERROR: version.dll not found at {version_dll}")
+ print("Make sure the lovely injector is installed.")
+ sys.exit(1)
+
+ # Kill existing processes
+ kill_port(args.port)
+ kill_balatro()
+
+ # Create logs directory
+ LOGS_DIR.mkdir(exist_ok=True)
+
+ # Set environment variables
+ env = os.environ.copy()
+ env["BALATROBOT_HOST"] = args.host
+ env["BALATROBOT_PORT"] = str(args.port)
+
+ if args.headless:
+ env["BALATROBOT_HEADLESS"] = "1"
+ if args.fast:
+ env["BALATROBOT_FAST"] = "1"
+ if args.render_on_api:
+ env["BALATROBOT_RENDER_ON_API"] = "1"
+ if args.audio:
+ env["BALATROBOT_AUDIO"] = "1"
+ if args.debug:
+ env["BALATROBOT_DEBUG"] = "1"
+ if args.no_shaders:
+ env["BALATROBOT_NO_SHADERS"] = "1"
+
+ # Open log file and start Balatro
+ log_file = LOGS_DIR / f"balatro_{args.port}.log"
+ with open(log_file, "w") as log:
+ process = subprocess.Popen(
+ [str(balatro_exe)],
+ env=env,
+ cwd=str(game_dir),
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+
+ # Wait for port to be ready
+ print(f"Waiting for port {args.port} to be ready...")
+ if not wait_for_port(args.host, args.port, timeout=30):
+ print(f"ERROR: Port {args.port} not ready after 30s. Check {log_file}")
+ if process.poll() is not None:
+ print("Balatro process has exited.")
+ sys.exit(1)
+
+ print("Balatro started successfully!")
+ print(f" Port: {args.port}")
+ print(f" PID: {process.pid}")
+ print(f" Log: {log_file}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Balatro launcher for Windows")
+
+ parser.add_argument(
+ "--host",
+ default="127.0.0.1",
+ help="Server host (default: 127.0.0.1)",
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=12346,
+ help="Server port (default: 12346)",
+ )
+ parser.add_argument(
+ "--headless",
+ action="store_true",
+ help="Run in headless mode",
+ )
+ parser.add_argument(
+ "--fast",
+ action="store_true",
+ help="Run in fast mode",
+ )
+ parser.add_argument(
+ "--render-on-api",
+ action="store_true",
+ help="Render only on API calls",
+ )
+ parser.add_argument(
+ "--audio",
+ action="store_true",
+ help="Enable audio",
+ )
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Enable debug mode",
+ )
+ parser.add_argument(
+ "--no-shaders",
+ action="store_true",
+ help="Disable all shaders",
+ )
+
+ args = parser.parse_args()
+ start(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/balatrobot/__init__.py b/src/balatrobot/__init__.py
deleted file mode 100644
index 090b078..0000000
--- a/src/balatrobot/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""BalatroBot - Python client for the BalatroBot game API."""
-
-from .client import BalatroClient
-from .enums import Actions, Decks, Stakes, State
-from .exceptions import BalatroError
-from .models import G
-
-__version__ = "0.7.5"
-__all__ = [
- # Main client
- "BalatroClient",
- # Enums
- "Actions",
- "Decks",
- "Stakes",
- "State",
- # Exception
- "BalatroError",
- # Models
- "G",
-]
diff --git a/src/balatrobot/client.py b/src/balatrobot/client.py
deleted file mode 100644
index 9f18c35..0000000
--- a/src/balatrobot/client.py
+++ /dev/null
@@ -1,501 +0,0 @@
-"""Main BalatroBot client for communicating with the game."""
-
-import json
-import logging
-import platform
-import re
-import shutil
-import socket
-import time
-from pathlib import Path
-from typing import Self
-
-from .enums import ErrorCode
-from .exceptions import (
- BalatroError,
- ConnectionFailedError,
- create_exception_from_error_response,
-)
-from .models import APIRequest
-
-logger = logging.getLogger(__name__)
-
-
-class BalatroClient:
- """Client for communicating with the BalatroBot game API.
-
- The client provides methods for game control, state management, and development tools
- including a checkpointing system for saving and loading game states.
-
- Attributes:
- host: Host address to connect to
- port: Port number to connect to
- timeout: Socket timeout in seconds
- buffer_size: Socket buffer size in bytes
- _socket: Socket connection to BalatroBot
- """
-
- host = "127.0.0.1"
- timeout = 300.0
- buffer_size = 65536
-
- def __init__(self, port: int = 12346, timeout: float | None = None):
- """Initialize BalatroBot client
-
- Args:
- port: Port number to connect to (default: 12346)
- timeout: Socket timeout in seconds (default: 300.0)
- """
- self.port = port
- self.timeout = timeout if timeout is not None else self.timeout
- self._socket: socket.socket | None = None
- self._connected = False
- self._message_buffer = b"" # Buffer for incomplete messages
-
- def _receive_complete_message(self) -> bytes:
- """Receive a complete message from the socket, handling message boundaries properly."""
- if not self._connected or not self._socket:
- raise ConnectionFailedError(
- "Socket not connected",
- error_code="E008",
- context={
- "connected": self._connected,
- "socket": self._socket is not None,
- },
- )
-
- # Check if we already have a complete message in the buffer
- while b"\n" not in self._message_buffer:
- try:
- chunk = self._socket.recv(self.buffer_size)
- except socket.timeout:
- raise ConnectionFailedError(
- "Socket timeout while receiving data",
- error_code="E008",
- context={
- "timeout": self.timeout,
- "buffer_size": len(self._message_buffer),
- },
- )
- except socket.error as e:
- raise ConnectionFailedError(
- f"Socket error while receiving: {e}",
- error_code="E008",
- context={"error": str(e), "buffer_size": len(self._message_buffer)},
- )
-
- if not chunk:
- raise ConnectionFailedError(
- "Connection closed by server",
- error_code="E008",
- context={"buffer_size": len(self._message_buffer)},
- )
- self._message_buffer += chunk
-
- # Extract the first complete message
- message_end = self._message_buffer.find(b"\n")
- complete_message = self._message_buffer[:message_end]
-
- # Update buffer to remove the processed message
- remaining_data = self._message_buffer[message_end + 1 :]
- self._message_buffer = remaining_data
-
- # Log any remaining data for debugging
- if remaining_data:
- logger.warning(f"Data remaining in buffer: {len(remaining_data)} bytes")
- logger.debug(f"Buffer preview: {remaining_data[:100]}...")
-
- return complete_message
-
- def __enter__(self) -> Self:
- """Enter context manager and connect to the game."""
- self.connect()
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
- """Exit context manager and disconnect from the game."""
- self.disconnect()
-
- def connect(self) -> None:
- """Connect to Balatro TCP server
-
- Raises:
- ConnectionFailedError: If not connected to the game
- """
- if self._connected:
- return
-
- logger.info(f"Connecting to BalatroBot API at {self.host}:{self.port}")
- try:
- self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- self._socket.settimeout(self.timeout)
- self._socket.setsockopt(
- socket.SOL_SOCKET, socket.SO_RCVBUF, self.buffer_size
- )
- self._socket.connect((self.host, self.port))
- self._connected = True
- logger.info(
- f"Successfully connected to BalatroBot API at {self.host}:{self.port}"
- )
- except (socket.error, OSError) as e:
- logger.error(f"Failed to connect to {self.host}:{self.port}: {e}")
- raise ConnectionFailedError(
- f"Failed to connect to {self.host}:{self.port}",
- error_code="E008",
- context={"host": self.host, "port": self.port, "error": str(e)},
- ) from e
-
- def disconnect(self) -> None:
- """Disconnect from the BalatroBot game API."""
- if self._socket:
- logger.info(f"Disconnecting from BalatroBot API at {self.host}:{self.port}")
- self._socket.close()
- self._socket = None
- self._connected = False
- # Clear message buffer on disconnect
- self._message_buffer = b""
-
- def send_message(self, name: str, arguments: dict | None = None) -> dict:
- """Send JSON message to Balatro and receive response
-
- Args:
- name: Function name to call
- arguments: Function arguments
-
- Returns:
- Response from the game API
-
- Raises:
- ConnectionFailedError: If not connected to the game
- BalatroError: If the API returns an error
- """
- if arguments is None:
- arguments = {}
-
- if not self._connected or not self._socket:
- raise ConnectionFailedError(
- "Not connected to the game API",
- error_code="E008",
- context={
- "connected": self._connected,
- "socket": self._socket is not None,
- },
- )
-
- # Create and validate request
- request = APIRequest(name=name, arguments=arguments)
- logger.debug(f"Sending API request: {name}")
-
- try:
- # Start timing measurement
- start_time = time.perf_counter()
-
- # Send request
- message = request.model_dump_json() + "\n"
- self._socket.send(message.encode())
-
- # Receive response using improved message handling
- complete_message = self._receive_complete_message()
-
- # Decode and validate the message
- message_str = complete_message.decode().strip()
- logger.debug(f"Raw message length: {len(message_str)} characters")
- logger.debug(f"Message preview: {message_str[:100]}...")
-
- # Ensure the message is properly formatted JSON
- if not message_str:
- raise BalatroError(
- "Empty response received from game",
- error_code="E001",
- context={"raw_data_length": len(complete_message)},
- )
-
- response_data = json.loads(message_str)
-
- # Check for error response
- if "error" in response_data:
- logger.error(f"API request {name} failed: {response_data.get('error')}")
- raise create_exception_from_error_response(response_data)
-
- logger.debug(f"API request {name} completed successfully")
- return response_data
-
- except socket.timeout as e:
- # Calculate elapsed time and log timeout
- elapsed_time = time.perf_counter() - start_time
- logger.warning(
- f"Timeout on API request {name}: took {elapsed_time:.3f}s, "
- f"exceeded timeout of {self.timeout}s (port: {self.port})"
- )
- raise ConnectionFailedError(
- f"Socket timeout during communication: {e}",
- error_code="E008",
- context={"error": str(e), "elapsed_time": elapsed_time},
- ) from e
- except socket.error as e:
- logger.error(f"Socket error during API request {name}: {e}")
- raise ConnectionFailedError(
- f"Socket error during communication: {e}",
- error_code="E008",
- context={"error": str(e)},
- ) from e
- except json.JSONDecodeError as e:
- logger.error(f"Invalid JSON response from API request {name}: {e}")
- logger.error(f"Problematic message content: {message_str[:200]}...")
- logger.error(
- f"Message buffer state: {len(self._message_buffer)} bytes remaining"
- )
-
- # Clear the message buffer to prevent cascading errors
- if self._message_buffer:
- logger.warning("Clearing message buffer due to JSON parse error")
- self._message_buffer = b""
-
- raise BalatroError(
- f"Invalid JSON response from game: {e}",
- error_code="E001",
- context={"error": str(e), "message_preview": message_str[:100]},
- ) from e
-
- # Checkpoint Management Methods
-
- def _convert_windows_path_to_linux(self, windows_path: str) -> str:
- """Convert Windows path to Linux Steam Proton path if on Linux.
-
- Args:
- windows_path: Windows-style path (e.g., "C:/Users/.../Balatro/3/save.jkr")
-
- Returns:
- Converted path for Linux or original path for other platforms
- """
-
- if platform.system() == "Linux":
- # Match Windows drive letter and path (e.g., "C:/...", "D:\\...", "E:...")
- match = re.match(r"^([A-Z]):[\\/]*(.*)", windows_path, re.IGNORECASE)
- if match:
- # Replace drive letter with Linux Steam Proton prefix
- linux_prefix = str(
- Path(
- "~/.steam/steam/steamapps/compatdata/2379780/pfx/drive_c"
- ).expanduser()
- )
- # Normalize slashes and join with prefix
- rest_of_path = match.group(2).replace("\\", "/")
- return linux_prefix + "/" + rest_of_path
-
- return windows_path
-
- def get_save_info(self) -> dict:
- """Get the current save file location and profile information.
-
- Development tool for working with save files and checkpoints.
-
- Returns:
- Dictionary containing:
- - profile_path: Current profile path (e.g., "3")
- - save_directory: Full path to Love2D save directory
- - save_file_path: Full OS-specific path to save.jkr file
- - has_active_run: Whether a run is currently active
- - save_exists: Whether the save file exists
-
- Raises:
- BalatroError: If request fails
-
- Note:
- This is primarily for development and testing purposes.
- """
- save_info = self.send_message("get_save_info")
-
- # Convert Windows paths to Linux Steam Proton paths if needed
- if "save_file_path" in save_info and save_info["save_file_path"]:
- save_info["save_file_path"] = self._convert_windows_path_to_linux(
- save_info["save_file_path"]
- )
- if "save_directory" in save_info and save_info["save_directory"]:
- save_info["save_directory"] = self._convert_windows_path_to_linux(
- save_info["save_directory"]
- )
-
- return save_info
-
- def save_checkpoint(self, checkpoint_name: str | Path) -> Path:
- """Save the current save.jkr file as a checkpoint.
-
- Args:
- checkpoint_name: Either:
- - A checkpoint name (saved to checkpoints dir)
- - A full file path where the checkpoint should be saved
- - A directory path (checkpoint will be saved as 'save.jkr' inside it)
-
- Returns:
- Path to the saved checkpoint file
-
- Raises:
- BalatroError: If no save file exists or the destination path is invalid
- IOError: If file operations fail
- """
- # Get current save info
- save_info = self.get_save_info()
- if not save_info.get("save_exists"):
- raise BalatroError(
- "No save file exists to checkpoint", ErrorCode.INVALID_GAME_STATE
- )
-
- # Get the full save file path from API (already OS-specific)
- save_path = Path(save_info["save_file_path"])
- if not save_path.exists():
- raise BalatroError(
- f"Save file not found: {save_path}", ErrorCode.MISSING_GAME_OBJECT
- )
-
- # Normalize and interpret destination
- dest = Path(checkpoint_name).expanduser()
- # Treat paths without a .jkr suffix as directories
- if dest.suffix.lower() != ".jkr":
- raise BalatroError(
- f"Invalid checkpoint path provided: {dest}",
- ErrorCode.INVALID_PARAMETER,
- context={"path": str(dest), "reason": "Path does not end with .jkr"},
- )
-
- # Ensure destination directory exists
- try:
- dest.parent.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- raise BalatroError(
- f"Invalid checkpoint path provided: {dest}",
- ErrorCode.INVALID_PARAMETER,
- context={"path": str(dest), "reason": str(e)},
- ) from e
-
- # Copy save file to checkpoint
- try:
- shutil.copy2(save_path, dest)
- except OSError as e:
- raise BalatroError(
- f"Failed to write checkpoint to: {dest}",
- ErrorCode.INVALID_PARAMETER,
- context={"path": str(dest), "reason": str(e)},
- ) from e
-
- return dest
-
- def prepare_save(self, source_path: str | Path) -> str:
- """Prepare a test save file for use with load_save.
-
- This copies a .jkr file from your test directory into Love2D's save directory
- in a temporary profile so it can be loaded with load_save().
-
- Args:
- source_path: Path to the .jkr save file to prepare
-
- Returns:
- The Love2D-relative path to use with load_save()
- (e.g., "checkpoint/save.jkr")
-
- Raises:
- BalatroError: If source file not found
- IOError: If file operations fail
- """
- source = Path(source_path)
- if not source.exists():
- raise BalatroError(
- f"Source save file not found: {source}", ErrorCode.MISSING_GAME_OBJECT
- )
-
- # Get save directory info
- save_info = self.get_save_info()
- if not save_info.get("save_directory"):
- raise BalatroError(
- "Cannot determine Love2D save directory", ErrorCode.INVALID_GAME_STATE
- )
-
- checkpoints_profile = "checkpoint"
- save_dir = Path(save_info["save_directory"])
- checkpoints_dir = save_dir / checkpoints_profile
- checkpoints_dir.mkdir(parents=True, exist_ok=True)
-
- # Copy the save file to the test profile
- dest_path = checkpoints_dir / "save.jkr"
- shutil.copy2(source, dest_path)
-
- # Return the Love2D-relative path
- return f"{checkpoints_profile}/save.jkr"
-
- def load_save(self, save_path: str | Path) -> dict:
- """Load a save file directly without requiring a game restart.
-
- This method loads a save file (in Love2D's save directory format) and starts
- a run from that save state. Unlike load_checkpoint which copies to the profile's
- save location and requires restart, this directly loads the save into the game.
-
- This is particularly useful for testing as it allows you to quickly jump to
- specific game states without manual setup.
-
- Args:
- save_path: Path to the save file relative to Love2D save directory
- (e.g., "3/save.jkr" for profile 3's save)
-
- Returns:
- Game state after loading the save
-
- Raises:
- BalatroError: If save file not found or loading fails
-
- Note:
- This is a development tool that bypasses normal game flow.
- Use with caution in production bots.
-
- Example:
- ```python
- # Load a profile's save directly
- game_state = client.load_save("3/save.jkr")
-
- # Or use with prepare_save for external files
- save_path = client.prepare_save("tests/fixtures/shop_state.jkr")
- game_state = client.load_save(save_path)
- ```
- """
- # Convert to string if Path object
- if isinstance(save_path, Path):
- save_path = str(save_path)
-
- # Send load_save request to API
- return self.send_message("load_save", {"save_path": save_path})
-
- def load_absolute_save(self, save_path: str | Path) -> dict:
- """Load a save from an absolute path. Takes a full path from the OS as a .jkr file and loads it into the game.
-
- Args:
- save_path: Path to the save file relative to Love2D save directory
- (e.g., "3/save.jkr" for profile 3's save)
-
- Returns:
- Game state after loading the save
- """
- love_save_path = self.prepare_save(save_path)
- return self.load_save(love_save_path)
-
- def screenshot(self, path: Path | None = None) -> Path:
- """
- Take a screenshot and save as both PNG and JPEG formats.
-
- Args:
- path: Optional path for PNG file. If provided, PNG will be moved to this location.
-
- Returns:
- Path to the PNG screenshot. JPEG is saved alongside with .jpg extension.
-
- Note:
- The response now includes both 'path' (PNG) and 'jpeg_path' (JPEG) keys.
- This method maintains backward compatibility by returning the PNG path.
- """
- screenshot_response = self.send_message("screenshot", {})
-
- if path is None:
- return Path(screenshot_response["path"])
- else:
- source_path = Path(screenshot_response["path"])
- dest_path = path
- shutil.move(source_path, dest_path)
- return dest_path
diff --git a/src/balatrobot/enums.py b/src/balatrobot/enums.py
deleted file mode 100644
index 9cbf4c3..0000000
--- a/src/balatrobot/enums.py
+++ /dev/null
@@ -1,478 +0,0 @@
-from enum import Enum, unique
-
-
-@unique
-class State(Enum):
- """Game state values representing different phases of gameplay in Balatro,
- from menu navigation to active card play and shop interactions."""
-
- SELECTING_HAND = 1
- HAND_PLAYED = 2
- DRAW_TO_HAND = 3
- GAME_OVER = 4
- SHOP = 5
- PLAY_TAROT = 6
- BLIND_SELECT = 7
- ROUND_EVAL = 8
- TAROT_PACK = 9
- PLANET_PACK = 10
- MENU = 11
- TUTORIAL = 12
- SPLASH = 13
- SANDBOX = 14
- SPECTRAL_PACK = 15
- DEMO_CTA = 16
- STANDARD_PACK = 17
- BUFFOON_PACK = 18
- NEW_ROUND = 19
-
-
-@unique
-class Actions(Enum):
- """Bot action values corresponding to user interactions available in
- different game states, from card play to shop purchases and inventory
- management."""
-
- SELECT_BLIND = 1
- SKIP_BLIND = 2
- PLAY_HAND = 3
- DISCARD_HAND = 4
- END_SHOP = 5
- REROLL_SHOP = 6
- BUY_CARD = 7
- BUY_VOUCHER = 8
- BUY_BOOSTER = 9
- SELECT_BOOSTER_CARD = 10
- SKIP_BOOSTER_PACK = 11
- SELL_JOKER = 12
- USE_CONSUMABLE = 13
- SELL_CONSUMABLE = 14
- REARRANGE_JOKERS = 15
- REARRANGE_CONSUMABLES = 16
- REARRANGE_HAND = 17
- PASS = 18
- START_RUN = 19
- SEND_GAMESTATE = 20
-
-
-@unique
-class Decks(Enum):
- """Starting deck types in Balatro, each providing unique starting
- conditions, card modifications, or special abilities that affect gameplay
- throughout the run."""
-
- 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"
-
-
-@unique
-class Stakes(Enum):
- """Difficulty stake levels in Balatro that increase game difficulty through
- various modifiers and restrictions, with higher stakes providing greater
- challenges and rewards."""
-
- WHITE = 1
- RED = 2
- GREEN = 3
- BLACK = 4
- BLUE = 5
- PURPLE = 6
- ORANGE = 7
- GOLD = 8
-
-
-@unique
-class ErrorCode(Enum):
- """Standardized error codes used in BalatroBot API that match those defined in src/lua/api.lua for consistent error handling across the entire system."""
-
- # Protocol errors (E001-E005)
- INVALID_JSON = "E001"
- MISSING_NAME = "E002"
- MISSING_ARGUMENTS = "E003"
- UNKNOWN_FUNCTION = "E004"
- INVALID_ARGUMENTS = "E005"
-
- # Network errors (E006-E008)
- SOCKET_CREATE_FAILED = "E006"
- SOCKET_BIND_FAILED = "E007"
- CONNECTION_FAILED = "E008"
-
- # Validation errors (E009-E012)
- INVALID_GAME_STATE = "E009"
- INVALID_PARAMETER = "E010"
- PARAMETER_OUT_OF_RANGE = "E011"
- MISSING_GAME_OBJECT = "E012"
-
- # Game logic errors (E013-E016)
- DECK_NOT_FOUND = "E013"
- INVALID_CARD_INDEX = "E014"
- NO_DISCARDS_LEFT = "E015"
- INVALID_ACTION = "E016"
-
-
-@unique
-class Jokers(Enum):
- """Joker cards available in Balatro with their effects."""
-
- # Common Jokers (Rarity 1)
- j_joker = "+4 Mult"
- j_greedy_joker = "+3 Mult if played hand contains a Diamond"
- j_lusty_joker = "+3 Mult if played hand contains a Heart"
- j_wrathful_joker = "+3 Mult if played hand contains a Spade"
- j_gluttenous_joker = "+3 Mult if played hand contains a Club"
- j_jolly = "+8 Mult if played hand contains a Pair"
- j_zany = "+12 Mult if played hand contains a Three of a Kind"
- j_mad = "+10 Mult if played hand contains a Two Pair"
- j_crazy = "+12 Mult if played hand contains a Straight"
- j_droll = "+10 Mult if played hand contains a Flush"
- j_sly = "+50 Chips if played hand contains a Pair"
- j_wily = "+100 Chips if played hand contains a Three of a Kind"
- j_clever = "+80 Chips if played hand contains a Two Pair"
- j_devious = "+100 Chips if played hand contains a Straight"
- j_crafty = "+80 Chips if played hand contains a Flush"
- j_half = "+20 Mult if played hand contains 3 or fewer cards"
- j_stencil = "×1 Mult for each empty Joker slot"
- j_four_fingers = "All Flushes and Straights can be made with 4 cards"
- j_mime = "Retrigger all card held in hand abilities"
- j_credit_card = "Go up to -$20 in debt"
- j_ceremonial = "When Blind is selected, destroy Joker to the right and permanently add double its sell value to this Mult"
- j_banner = "+30 Chips for each remaining discard"
- j_mystic_summit = "+15 Mult when 0 discards remaining"
- j_marble = "Adds one Stone card to deck when Blind is selected"
- j_loyalty_card = "×4 Mult every 6 hands played, ×1 Mult every 3 hands played"
- j_8_ball = "1 in 4 chance for each 8 played to create a Tarot card when scored"
- j_misprint = "+0 to +23 Mult"
- j_dusk = "Retrigger all played cards in final hand of round"
- j_raised_fist = "Adds double the rank of lowest ranked card held in hand to Mult"
- j_chaos = "1 free Reroll per shop"
- j_fibonacci = "Each played Ace, 2, 3, 5, or 8 gives +8 Mult when scored"
- j_steel_joker = "Gives ×1.5 Mult for each Steel Card in your full deck"
- j_scary_face = "Played face cards give +30 Chips when scored"
- j_abstract = "+3 Mult for each Joker card"
- j_delayed_grat = "Earn $2 per discard if no discards are used by end of round"
- j_hack = "Retrigger each played 2, 3, 4, or 5"
- j_pareidolia = "All cards are considered face cards"
- j_gros_michel = "+15 Mult, 1 in 4 chance this card is destroyed at end of round"
- j_even_steven = "Played cards with even rank give +4 Mult when scored"
- j_odd_todd = "Played cards with odd rank give +31 Chips when scored"
- j_scholar = "Played Aces give +20 Chips and +4 Mult when scored"
- j_business = "Played face cards have a 1 in 2 chance to give $2 when scored"
- j_supernova = "Adds the number of times poker hand has been played this run to Mult"
- j_ride_the_bus = "This Joker gains +1 Mult per consecutive hand played without a face card, resets when face card is played"
- j_space = "1 in 4 chance to upgrade level of played poker hand"
- j_egg = "Gains $3 of sell value at end of round"
- j_burglar = "When Blind is selected, gain +3 hands and lose all discards"
- j_blackboard = "×3 Mult if all cards held in hand are Spades or Clubs"
- j_runner = "Gains +15 Chips if played hand contains a Straight"
- j_ice_cream = "+100 Chips, -5 Chips for every hand played"
- j_dna = "If first hand of round has only 1 card, add a permanent copy to deck and draw it to hand"
- j_splash = "Every played card counts in scoring"
- j_blue_joker = "+2 Chips for each remaining card in deck"
- j_sixth_sense = (
- "If first hand of round is a single 6, destroy it and create a Spectral card"
- )
- j_constellation = "This Joker gains ×0.1 Mult every time a Planet card is used"
- j_hiker = "Every played card permanently gains +5 Chips when scored"
- j_faceless = "Earn $5 if 3 or more face cards are discarded at the same time"
- j_green_joker = "+1 Mult per hand played, -1 Mult per discard"
- j_superposition = "Create a Tarot card if poker hand contains an Ace and a Straight"
- j_todo_list = "Earn $4 if poker hand is a Pair, poker hand changes at end of round"
- j_cavendish = "×3 Mult, 1 in 1000 chance this card is destroyed at end of round"
- j_card_sharp = "×3 Mult if played poker hand has already been played this round"
- j_red_card = "This Joker gains +3 Mult when any Booster Pack is skipped"
- j_madness = "When Small Blind or Big Blind is selected, gain ×0.5 Mult and destroy a random Joker"
- j_square = "This Joker gains +4 Chips if played hand has exactly 4 cards"
- j_seance = "If poker hand is a Straight Flush, create a random Spectral card"
- j_riff_raff = "When Blind is selected, create 2 Common Jokers"
- j_vampire = (
- "This Joker gains ×0.1 Mult per Enhanced card played, removes card Enhancement"
- )
- j_shortcut = "Allows Straights to be made with gaps of 1 rank"
- j_hologram = (
- "This Joker gains ×0.25 Mult every time a playing card is added to your deck"
- )
- j_vagabond = "Create a Tarot card if hand is played with $4 or less"
- j_baron = "Each King held in hand gives ×1.5 Mult"
- j_cloud_9 = "Earn $1 for each 9 in your full deck at end of round"
- j_rocket = (
- "Earn $1 at end of round, payout increases by $2 when Boss Blind is defeated"
- )
- j_obelisk = "This Joker gains ×0.2 Mult per consecutive hand played without playing your most played poker hand"
- j_midas_mask = "All played face cards become Gold cards when scored"
- j_luchador = "Sell this card to disable the current Boss Blind"
- j_photograph = "First played face card gives ×2 Mult"
- j_gift = "Add $1 of sell value to every Joker and Consumable card at end of round"
- j_turtle_bean = "+5 hand size, reduces by 1 each round"
- j_erosion = "+4 Mult for each card below 52 in your full deck"
- j_reserved_parking = "Each face card held in hand has a 1 in 3 chance to give $1"
- j_mail = "Earn $3 for each discarded rank, rank changes every round"
- j_to_the_moon = "Earn an extra $1 of interest for every $5 you have at end of round"
- j_hallucination = (
- "1 in 2 chance to create a Tarot card when any Booster Pack is opened"
- )
- j_fortune_teller = "+1 Mult per Tarot card used this run"
- j_juggler = "+1 hand size"
- j_drunkard = "+1 discard"
- j_stone = "Gives +25 Chips for each Stone Card in your full deck"
- j_golden = "Earn $4 at end of round"
- j_lucky_cat = (
- "This Joker gains ×0.25 Mult every time a Lucky card successfully triggers"
- )
- j_baseball = "Uncommon Jokers each give ×1.5 Mult"
- j_bull = "+2 Chips for each dollar you have"
- j_diet_cola = "Sell this card to create a free Double Tag"
- j_trading = "If first discard of round has only 1 card, destroy it and earn $3"
- j_flash = "This Joker gains +2 Mult per reroll in the shop"
- j_popcorn = "+20 Mult, -4 Mult per round played"
- j_ramen = "×2 Mult, loses ×0.01 Mult per card discarded"
- j_trousers = "This Joker gains +2 Mult if played hand contains a Two Pair"
- j_ancient = "Each played card with suit gives ×1.5 Mult when scored, suit changes at end of round"
- j_walkie_talkie = "Each played 10 or 4 gives +10 Chips and +4 Mult when scored"
- j_selzer = "Retrigger all cards played for the next 10 hands"
- j_castle = "This Joker gains +3 Chips per discarded card, suit changes every round"
- j_smiley = "Played face cards give +5 Mult when scored"
- j_campfire = "This Joker gains ×0.5 Mult for each card sold, resets when Boss Blind is defeated"
- j_golden_ticket = "Played Gold cards earn $4 when scored"
- j_mr_bones = "Prevents death if chips scored are at least 25% of required chips"
- j_acrobat = "×3 Mult on final hand of round"
- j_sock_and_buskin = "Retrigger all played face cards"
- j_swashbuckler = "Adds the sell value of all other owned Jokers to Mult"
- j_troubadour = "+2 hand size, -1 hand per round"
- j_certificate = (
- "When round begins, add a random playing card with a random seal to your hand"
- )
- j_smeared = "Hearts and Diamonds count as the same suit, Spades and Clubs count as the same suit"
- j_throwback = "×0.25 Mult for each skipped Blind this run"
- j_hanging_chad = "Retrigger first played card 2 additional times"
- j_rough_gem = "Played cards with Diamond suit earn $1 when scored"
- j_bloodstone = (
- "1 in 3 chance for played cards with Heart suit to give ×1.5 Mult when scored"
- )
- j_arrowhead = "Played cards with Spade suit give +50 Chips when scored"
- j_onyx_agate = "Played cards with Club suit give +7 Mult when scored"
- j_glass = "Gives ×2 Mult for each Glass Card in your full deck"
- j_ring_master = "Joker, Tarot, Planet, and Spectral cards may appear multiple times"
- j_flower_pot = "×3 Mult if poker hand contains a Diamond card, a Club card, a Heart card, and a Spade card"
- j_blueprint = "Copies ability of Joker to the right"
- j_wee = "This Joker gains +8 Chips when each played 2 is scored"
- j_merry_andy = "+3 discards, -1 hand size"
- j_oops = "All number cards are 6s"
- j_idol = (
- "Each played card of rank gives ×2 Mult when scored, rank changes every round"
- )
- j_seeing_double = "×2 Mult if played hand has a scoring Club card and a scoring card of any other suit"
- j_matador = "Earn $8 if played hand triggers the Boss Blind ability"
- j_hit_the_road = "This Joker gains ×0.5 Mult for every Jack discarded this round"
- j_duo = "×2 Mult if played hand contains a Pair"
- j_trio = "×3 Mult if played hand contains a Three of a Kind"
- j_family = "×4 Mult if played hand contains a Four of a Kind"
- j_order = "×3 Mult if played hand contains a Straight"
- j_tribe = "×2 Mult if played hand contains a Flush"
- j_stuntman = "+250 Chips, -2 hand size"
- j_invisible = "After 2 rounds, sell this card to Duplicate a random Joker"
- j_brainstorm = "Copies the ability of leftmost Joker"
- j_satellite = "Earn $1 at end of round per unique Planet card used this run"
- j_shoot_the_moon = "Each Queen held in hand gives +13 Mult"
- j_drivers_license = (
- "×3 Mult if you have at least 16 Enhanced cards in your full deck"
- )
- j_cartomancer = "Create a Tarot card when Blind is selected"
- j_astronomer = "All Planet cards and Celestial Packs in the shop are free"
- j_burnt = "Upgrade the level of the first discarded poker hand each round"
- j_bootstraps = "+2 Mult for every $5 you have"
- j_canio = "This Joker gains ×1 Mult when a face card is destroyed"
- j_triboulet = "Played Kings and Queens each give ×2 Mult when scored"
- j_yorick = "This Joker gains ×1 Mult every 23 cards discarded"
- j_chicot = "Disables effect of every Boss Blind"
- j_perkeo = "Creates a Negative copy of 1 random Consumable card in your possession at the end of the shop"
-
-
-@unique
-class Consumables(Enum):
- """Consumable cards available in Balatro with their effects."""
-
- # Tarot consumable cards and their effects.
-
- c_fool = (
- "Creates the last Tarot or Planet Card used during this run (The Fool excluded)"
- )
- c_magician = "Enhances 2 selected cards to Lucky Cards"
- c_high_priestess = "Creates up to 2 random Planet cards (Must have room)"
- c_empress = "Enhances 2 selected cards to Mult Cards"
- c_emperor = "Creates up to 2 random Tarot cards (Must have room)"
- c_hierophant = "Enhances 2 selected cards to Bonus Cards"
- c_lovers = "Enhances 1 selected card to a Wild Card"
- c_chariot = "Enhances 1 selected card to a Steel Card"
- c_justice = "Enhances 1 selected card to a Glass Card"
- c_hermit = "Doubles money (max of $20)"
- c_wheel_of_fortune = "1 in 4 chance to add Foil, Holographic, or Polychrome edition to a random Joker"
- c_strength = "Increases rank of up to 2 selected cards by 1"
- c_hanged_man = "Destroys up to 2 selected cards"
- c_death = "Select 2 cards, convert the left into the right"
- c_temperance = "Gives the total sell value of all current Jokers (Max of $50)"
- c_devil = "Enhances 1 selected card to a Gold Card"
- c_tower = "Enhances 1 selected card to a Stone Card"
- c_star = "Converts up to 3 selected cards to Diamonds"
- c_moon = "Converts up to 3 selected cards to Clubs"
- c_sun = "Converts up to 3 selected cards to Hearts"
- c_judgement = "Creates a random Joker card (Must have room)"
- c_world = "Converts up to 3 selected cards to Spades"
-
- # Planet consumable cards that level up poker hands.
-
- c_mercury = "Levels up Pair"
- c_venus = "Levels up Three of a Kind"
- c_earth = "Levels up Full House"
- c_mars = "Levels up Four of a Kind"
- c_jupiter = "Levels up Flush"
- c_saturn = "Levels up Straight"
- c_uranus = "Levels up Two Pair"
- c_neptune = "Levels up Straight Flush"
- c_pluto = "Levels up High Card"
- c_planet_x = "Levels up Flush House"
- c_ceres = "Levels up Five of a Kind"
- c_eris = "Levels up Flush Five"
-
- # Spectral consumable cards with powerful effects.
-
- c_familiar = "Destroy 1 random card in your hand, add 3 random Enhanced face cards to your hand"
- c_grim = (
- "Destroy 1 random card in your hand, add 2 random Enhanced Aces to your hand"
- )
- c_incantation = "Destroy 1 random card in your hand, add 4 random Enhanced numbered cards to your hand"
- c_talisman = "Add a Gold Seal to 1 selected card"
- c_aura = "Add Foil, Holographic, or Polychrome effect to 1 selected card"
- c_wraith = "Creates a random Rare Joker, sets money to $0"
- c_sigil = "Converts all cards in hand to a single random suit"
- c_ouija = "Converts all cards in hand to a single random rank, -1 hand size"
- c_ectoplasm = "Add Negative to a random Joker, -1 hand size for rest of run"
- c_immolate = "Destroys 5 random cards in hand, gain $20"
- c_ankh = "Create a copy of a random Joker, destroy all other Jokers"
- c_deja_vu = "Add a Red Seal to 1 selected card"
- c_hex = "Add Polychrome to a random Joker, destroy all other Jokers"
- c_trance = "Add a Blue Seal to 1 selected card"
- c_medium = "Add a Purple Seal to 1 selected card"
- c_cryptid = "Create 2 copies of 1 selected card"
- c_soul = "Creates a Legendary Joker (Must have room)"
- c_black_hole = "Upgrade every poker hand by 1 level"
-
-
-@unique
-class Vouchers(Enum):
- """Voucher cards that provide permanent upgrades."""
-
- v_overstock_norm = "+1 card slot available in shop (to 3 slots)"
- v_clearance_sale = "All cards and packs in shop are 25% off"
- v_hone = "Foil, Holographic, and Polychrome cards appear 2X more frequently"
- v_reroll_surplus = "Rerolls cost $2 less"
- v_crystal_ball = "+1 consumable slot"
- v_telescope = (
- "Celestial Packs always contain the Planet card for your most played poker hand"
- )
- v_grabber = "Permanently gain +1 hand per round"
- v_wasteful = "Permanently gain +1 discard per round"
- v_tarot_merchant = "Tarot cards appear 2X more frequently in the shop"
- v_planet_merchant = "Planet cards appear 2X more frequently in the shop"
- v_seed_money = "Raise the cap on interest earned in each round to $10"
- v_blank = "Does nothing"
- v_magic_trick = "Playing cards are available for purchase in the shop"
- v_hieroglyph = "-1 Ante, -1 hand each round"
- v_directors_cut = "Reroll Boss Blind 1 time per Ante, $10 per roll"
- v_paint_brush = "+1 hand size"
- v_overstock_plus = "+1 card slot available in shop (to 4 slots)"
- v_liquidation = "All cards and packs in shop are 50% off"
- v_glow_up = "Foil, Holographic, and Polychrome cards appear 4X more frequently"
- v_reroll_glut = "Rerolls cost an additional $2 less"
- v_omen_globe = "Spectral cards may appear in any of the Arcana Packs"
- v_observatory = "Planet cards in your consumable area give ×1.5 Mult for their specific poker hand"
- v_nacho_tong = "Permanently gain an additional +1 hand per round"
- v_recyclomancy = "Permanently gain an additional +1 discard per round"
- v_tarot_tycoon = "Tarot cards appear 4X more frequently in the shop"
- v_planet_tycoon = "Planet cards appear 4X more frequently in the shop"
- v_money_tree = "Raise the cap on interest earned in each round to $20"
- v_antimatter = "+1 Joker slot"
- v_illusion = "Playing cards in shop may have an Enhancement, Edition, or Seal"
- v_petroglyph = "-1 Ante again, -1 discard each round"
- v_retcon = "Reroll Boss Blind unlimited times, $10 per roll"
- v_palette = "+1 hand size again"
-
-
-@unique
-class Tags(Enum):
- """Tag rewards that provide various benefits."""
-
- tag_uncommon = "Shop has a free Uncommon Joker"
- tag_rare = "Shop has a free Rare Joker"
- tag_negative = "Next base edition shop Joker becomes Negative"
- tag_foil = "Next base edition shop Joker becomes Foil"
- tag_holo = "Next base edition shop Joker becomes Holographic"
- tag_polychrome = "Next base edition shop Joker becomes Polychrome"
- tag_investment = "After defeating this Boss Blind, gain $25"
- 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 = "Gain $1 for each hand played this run"
- tag_garbage = "Gain $1 for each 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, excluding subsequent Double Tags"
- )
- tag_juggle = "+3 Hand Size for the next round only"
- tag_d_six = "In the next Shop, Rerolls start at $0"
- tag_top_up = "Create up to 2 Common Jokers (if you have space)"
- tag_speed = "Gives $5 for each Blind you've skipped this run"
- tag_orbital = "Upgrade poker hand by 3 levels"
- tag_economy = "Doubles your money (max of $40)"
- tag_rush = "+1 Boss Blind reward"
- tag_skip = "Gives $5 plus $1 for every skipped Blind this run"
-
-
-@unique
-class Editions(Enum):
- """Special editions that can be applied to cards."""
-
- e_foil = "+50 Chips"
- e_holo = "+10 Mult"
- e_polychrome = "×1.5 Mult"
- e_negative = "+1 Joker slot"
-
-
-@unique
-class Enhancements(Enum):
- """Enhancements that can be applied to playing cards."""
-
- m_bonus = "+30 Chips when scored"
- m_mult = "+4 Mult when scored"
- m_wild = "Can be used as any suit"
- m_glass = "×2 Mult, 1 in 4 chance to destroy when scored"
- m_steel = "×1.5 Mult when this card stays in hand"
- m_stone = "+50 Chips when scored, no rank or suit"
- m_gold = "$3 when this card is held in hand at end of round"
- m_lucky = "1 in 5 chance for +20 Mult and 1 in 15 chance for $20 when scored"
-
-
-@unique
-class Seals(Enum):
- """Seals that can be applied to playing cards."""
-
- Red = "Retrigger this card 1 time. Retriggering means that the effect of the cards is applied again including counting again in the score calculation"
- Blue = "Creates the Planet card for the final poker hand played if held in hand at end of round (Must have room)"
- Gold = "$3 when this card is played and scores"
- Purple = "Creates a Tarot card when discarded (Must have room)"
diff --git a/src/balatrobot/exceptions.py b/src/balatrobot/exceptions.py
deleted file mode 100644
index 1ccd791..0000000
--- a/src/balatrobot/exceptions.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""Custom exceptions for BalatroBot API."""
-
-from typing import Any
-
-from .enums import ErrorCode
-
-
-class BalatroError(Exception):
- """Base exception for all BalatroBot errors."""
-
- def __init__(
- self,
- message: str,
- error_code: str | ErrorCode,
- state: int | None = None,
- context: dict[str, Any] | None = None,
- ) -> None:
- super().__init__(message)
- self.message = message
- self.error_code = (
- error_code if isinstance(error_code, ErrorCode) else ErrorCode(error_code)
- )
- self.state = state
- self.context = context or {}
-
- def __str__(self) -> str:
- return f"{self.error_code.value}: {self.message}"
-
- def __repr__(self) -> str:
- return f"{self.__class__.__name__}(message='{self.message}', error_code='{self.error_code.value}', state={self.state})"
-
-
-# Protocol errors (E001-E005)
-class InvalidJSONError(BalatroError):
- """Invalid JSON in request (E001)."""
-
- pass
-
-
-class MissingNameError(BalatroError):
- """Message missing required 'name' field (E002)."""
-
- pass
-
-
-class MissingArgumentsError(BalatroError):
- """Message missing required 'arguments' field (E003)."""
-
- pass
-
-
-class UnknownFunctionError(BalatroError):
- """Unknown function name (E004)."""
-
- pass
-
-
-class InvalidArgumentsError(BalatroError):
- """Invalid arguments provided (E005)."""
-
- pass
-
-
-# Network errors (E006-E008)
-class SocketCreateFailedError(BalatroError):
- """Socket creation failed (E006)."""
-
- pass
-
-
-class SocketBindFailedError(BalatroError):
- """Socket bind failed (E007)."""
-
- pass
-
-
-class ConnectionFailedError(BalatroError):
- """Connection failed (E008)."""
-
- pass
-
-
-# Validation errors (E009-E012)
-class InvalidGameStateError(BalatroError):
- """Invalid game state for requested action (E009)."""
-
- pass
-
-
-class InvalidParameterError(BalatroError):
- """Invalid or missing required parameter (E010)."""
-
- pass
-
-
-class ParameterOutOfRangeError(BalatroError):
- """Parameter value out of valid range (E011)."""
-
- pass
-
-
-class MissingGameObjectError(BalatroError):
- """Required game object missing (E012)."""
-
- pass
-
-
-# Game logic errors (E013-E016)
-class DeckNotFoundError(BalatroError):
- """Deck not found (E013)."""
-
- pass
-
-
-class InvalidCardIndexError(BalatroError):
- """Invalid card index (E014)."""
-
- pass
-
-
-class NoDiscardsLeftError(BalatroError):
- """No discards remaining (E015)."""
-
- pass
-
-
-class InvalidActionError(BalatroError):
- """Invalid action for current context (E016)."""
-
- pass
-
-
-# Mapping from error codes to exception classes
-ERROR_CODE_TO_EXCEPTION = {
- ErrorCode.INVALID_JSON: InvalidJSONError,
- ErrorCode.MISSING_NAME: MissingNameError,
- ErrorCode.MISSING_ARGUMENTS: MissingArgumentsError,
- ErrorCode.UNKNOWN_FUNCTION: UnknownFunctionError,
- ErrorCode.INVALID_ARGUMENTS: InvalidArgumentsError,
- ErrorCode.SOCKET_CREATE_FAILED: SocketCreateFailedError,
- ErrorCode.SOCKET_BIND_FAILED: SocketBindFailedError,
- ErrorCode.CONNECTION_FAILED: ConnectionFailedError,
- ErrorCode.INVALID_GAME_STATE: InvalidGameStateError,
- ErrorCode.INVALID_PARAMETER: InvalidParameterError,
- ErrorCode.PARAMETER_OUT_OF_RANGE: ParameterOutOfRangeError,
- ErrorCode.MISSING_GAME_OBJECT: MissingGameObjectError,
- ErrorCode.DECK_NOT_FOUND: DeckNotFoundError,
- ErrorCode.INVALID_CARD_INDEX: InvalidCardIndexError,
- ErrorCode.NO_DISCARDS_LEFT: NoDiscardsLeftError,
- ErrorCode.INVALID_ACTION: InvalidActionError,
-}
-
-
-def create_exception_from_error_response(
- error_response: dict[str, Any],
-) -> BalatroError:
- """Create an appropriate exception from an error response."""
- error_code = ErrorCode(error_response["error_code"])
- exception_class = ERROR_CODE_TO_EXCEPTION.get(error_code, BalatroError)
-
- return exception_class(
- message=error_response["error"],
- error_code=error_code,
- state=error_response["state"],
- context=error_response.get("context"),
- )
diff --git a/src/balatrobot/models.py b/src/balatrobot/models.py
deleted file mode 100644
index e732399..0000000
--- a/src/balatrobot/models.py
+++ /dev/null
@@ -1,402 +0,0 @@
-"""Pydantic models for BalatroBot API matching Lua types structure."""
-
-from typing import Any, Literal
-
-from pydantic import BaseModel, ConfigDict, Field, field_validator
-
-from .enums import State
-
-
-class BalatroBaseModel(BaseModel):
- """Base model for all BalatroBot API models."""
-
- model_config = ConfigDict(
- extra="allow",
- str_strip_whitespace=True,
- validate_assignment=True,
- frozen=True,
- )
-
-
-# =============================================================================
-# Request Models (keep existing - they match Lua argument types)
-# =============================================================================
-
-
-class StartRunRequest(BalatroBaseModel):
- """Request model for starting a new run."""
-
- deck: str = Field(..., description="Name of the deck to use")
- stake: int = Field(1, ge=1, le=8, description="Stake level (1-8)")
- seed: str | None = Field(None, description="Optional seed for the run")
- challenge: str | None = Field(None, description="Optional challenge name")
-
-
-class BlindActionRequest(BalatroBaseModel):
- """Request model for skip or select blind actions."""
-
- action: Literal["skip", "select"] = Field(
- ..., description="Action to take with the blind"
- )
-
-
-class HandActionRequest(BalatroBaseModel):
- """Request model for playing hand or discarding cards."""
-
- action: Literal["play_hand", "discard"] = Field(
- ..., description="Action to take with the cards"
- )
- cards: list[int] = Field(
- ..., min_length=1, max_length=5, description="List of card indices (0-indexed)"
- )
-
-
-class ShopActionRequest(BalatroBaseModel):
- """Request model for shop actions."""
-
- action: Literal["next_round"] = Field(..., description="Shop action to perform")
-
-
-# =============================================================================
-# Game State Models (matching src/lua/types.lua)
-# =============================================================================
-
-
-class GGameTags(BalatroBaseModel):
- """Game tags model matching GGameTags in Lua types."""
-
- key: str = Field("", description="Tag ID (e.g., 'tag_foil')")
- name: str = Field("", description="Tag display name (e.g., 'Foil Tag')")
-
-
-class GGameLastBlind(BalatroBaseModel):
- """Last blind info matching GGameLastBlind in Lua types."""
-
- boss: bool = Field(False, description="Whether the last blind was a boss")
- name: str = Field("", description="Name of the last blind")
-
-
-class GGameCurrentRound(BalatroBaseModel):
- """Current round info matching GGameCurrentRound in Lua types."""
-
- discards_left: int = Field(0, description="Number of discards remaining")
- discards_used: int = Field(0, description="Number of discards used")
- hands_left: int = Field(0, description="Number of hands remaining")
- hands_played: int = Field(0, description="Number of hands played")
- voucher: dict[str, Any] = Field(
- default_factory=dict, description="Vouchers for this round"
- )
-
- @field_validator("voucher", mode="before")
- @classmethod
- def convert_empty_list_to_dict(cls, v):
- """Convert empty list to empty dict."""
- return {} if v == [] else v
-
-
-class GGameSelectedBack(BalatroBaseModel):
- """Selected deck info matching GGameSelectedBack in Lua types."""
-
- name: str = Field("", description="Name of the selected deck")
-
-
-class GGameShop(BalatroBaseModel):
- """Shop configuration matching GGameShop in Lua types."""
-
- joker_max: int = Field(0, description="Maximum jokers in shop")
-
-
-class GGameStartingParams(BalatroBaseModel):
- """Starting parameters matching GGameStartingParams in Lua types."""
-
- boosters_in_shop: int = Field(0, description="Number of boosters in shop")
- reroll_cost: int = Field(0, description="Cost to reroll shop")
- hand_size: int = Field(0, description="Starting hand size")
- hands: int = Field(0, description="Starting hands per round")
- ante_scaling: int = Field(0, description="Ante scaling factor")
- consumable_slots: int = Field(0, description="Number of consumable slots")
- dollars: int = Field(0, description="Starting money")
- discards: int = Field(0, description="Starting discards per round")
- joker_slots: int = Field(0, description="Number of joker slots")
- vouchers_in_shop: int = Field(0, description="Number of vouchers in shop")
-
-
-class GGamePreviousRound(BalatroBaseModel):
- """Previous round info matching GGamePreviousRound in Lua types."""
-
- dollars: int = Field(0, description="Dollars from previous round")
-
-
-class GGameProbabilities(BalatroBaseModel):
- """Game probabilities matching GGameProbabilities in Lua types."""
-
- normal: float = Field(1.0, description="Normal probability modifier")
-
-
-class GGamePseudorandom(BalatroBaseModel):
- """Pseudorandom data matching GGamePseudorandom in Lua types."""
-
- seed: str = Field("", description="Pseudorandom seed")
-
-
-class GGameRoundBonus(BalatroBaseModel):
- """Round bonus matching GGameRoundBonus in Lua types."""
-
- next_hands: int = Field(0, description="Bonus hands for next round")
- discards: int = Field(0, description="Bonus discards")
-
-
-class GGameRoundScores(BalatroBaseModel):
- """Round scores matching GGameRoundScores in Lua types."""
-
- cards_played: dict[str, Any] = Field(
- default_factory=dict, description="Cards played stats"
- )
- cards_discarded: dict[str, Any] = Field(
- default_factory=dict, description="Cards discarded stats"
- )
- furthest_round: dict[str, Any] = Field(
- default_factory=dict, description="Furthest round stats"
- )
- furthest_ante: dict[str, Any] = Field(
- default_factory=dict, description="Furthest ante stats"
- )
-
-
-class GGame(BalatroBaseModel):
- """Game state matching GGame in Lua types."""
-
- bankrupt_at: int = Field(0, description="Money threshold for bankruptcy")
- base_reroll_cost: int = Field(0, description="Base cost for rerolling shop")
- blind_on_deck: str = Field("", description="Current blind type")
- bosses_used: dict[str, int] = Field(
- default_factory=dict, description="Bosses used in run"
- )
- chips: int = Field(0, description="Current chip count")
- current_round: GGameCurrentRound | None = Field(
- None, description="Current round information"
- )
- discount_percent: int = Field(0, description="Shop discount percentage")
- dollars: int = Field(0, description="Current money amount")
- hands_played: int = Field(0, description="Total hands played in the run")
- inflation: int = Field(0, description="Current inflation rate")
- interest_amount: int = Field(0, description="Interest amount per dollar")
- interest_cap: int = Field(0, description="Maximum interest that can be earned")
- last_blind: GGameLastBlind | None = Field(
- None, description="Last blind information"
- )
- max_jokers: int = Field(0, description="Maximum number of jokers allowed")
- planet_rate: int = Field(0, description="Probability for planet cards in shop")
- playing_card_rate: int = Field(
- 0, description="Probability for playing cards in shop"
- )
- previous_round: GGamePreviousRound | None = Field(
- None, description="Previous round information"
- )
- probabilities: GGameProbabilities | None = Field(
- None, description="Various game probabilities"
- )
- pseudorandom: GGamePseudorandom | None = Field(
- None, description="Pseudorandom seed data"
- )
- round: int = Field(0, description="Current round number")
- round_bonus: GGameRoundBonus | None = Field(
- None, description="Round bonus information"
- )
- round_scores: GGameRoundScores | None = Field(
- None, description="Round scoring data"
- )
- seeded: bool = Field(False, description="Whether the run uses a seed")
- selected_back: GGameSelectedBack | None = Field(
- None, description="Selected deck information"
- )
- shop: GGameShop | None = Field(None, description="Shop configuration")
- skips: int = Field(0, description="Number of skips used")
- smods_version: str = Field("", description="SMODS version")
- stake: int = Field(0, description="Current stake level")
- starting_params: GGameStartingParams | None = Field(
- None, description="Starting parameters"
- )
- tags: list[GGameTags] = Field(default_factory=list, description="Array of tags")
- tarot_rate: int = Field(0, description="Probability for tarot cards in shop")
- uncommon_mod: int = Field(0, description="Modifier for uncommon joker probability")
- unused_discards: int = Field(0, description="Unused discards from previous round")
- used_vouchers: dict[str, bool] | list = Field(
- default_factory=dict, description="Vouchers used in run"
- )
- voucher_text: str = Field("", description="Voucher text display")
- win_ante: int = Field(0, description="Ante required to win")
- won: bool = Field(False, description="Whether the run is won")
-
- @field_validator("bosses_used", "used_vouchers", mode="before")
- @classmethod
- def convert_empty_list_to_dict(cls, v):
- """Convert empty list to empty dict."""
- return {} if v == [] else v
-
- @field_validator(
- "previous_round",
- "probabilities",
- "pseudorandom",
- "round_bonus",
- "round_scores",
- "shop",
- "starting_params",
- mode="before",
- )
- @classmethod
- def convert_empty_list_to_none(cls, v):
- """Convert empty list to None for optional nested objects."""
- return None if v == [] else v
-
-
-class GHandCardsBase(BalatroBaseModel):
- """Hand card base properties matching GHandCardsBase in Lua types."""
-
- id: Any = Field(None, description="Card ID")
- name: str = Field("", description="Base card name")
- nominal: str = Field("", description="Nominal value")
- original_value: str = Field("", description="Original card value")
- suit: str = Field("", description="Card suit")
- times_played: int = Field(0, description="Times this card has been played")
- value: str = Field("", description="Current card value")
-
- @field_validator("nominal", "original_value", "value", mode="before")
- @classmethod
- def convert_int_to_string(cls, v):
- """Convert integer values to strings."""
- return str(v) if isinstance(v, int) else v
-
-
-class GHandCardsConfigCard(BalatroBaseModel):
- """Hand card config card data matching GHandCardsConfigCard in Lua types."""
-
- name: str = Field("", description="Card name")
- suit: str = Field("", description="Card suit")
- value: str = Field("", description="Card value")
-
-
-class GHandCardsConfig(BalatroBaseModel):
- """Hand card configuration matching GHandCardsConfig in Lua types."""
-
- card_key: str = Field("", description="Unique card identifier")
- card: GHandCardsConfigCard | None = Field(None, description="Card-specific data")
-
-
-class GHandCards(BalatroBaseModel):
- """Hand card matching GHandCards in Lua types."""
-
- label: str = Field("", description="Display label of the card")
- base: GHandCardsBase | None = Field(None, description="Base card properties")
- config: GHandCardsConfig | None = Field(None, description="Card configuration")
- debuff: bool = Field(False, description="Whether card is debuffed")
- facing: str = Field("front", description="Card facing direction")
- highlighted: bool = Field(False, description="Whether card is highlighted")
-
-
-class GHandConfig(BalatroBaseModel):
- """Hand configuration matching GHandConfig in Lua types."""
-
- card_count: int = Field(0, description="Number of cards in hand")
- card_limit: int = Field(0, description="Maximum cards allowed in hand")
- highlighted_limit: int = Field(
- 0, description="Maximum cards that can be highlighted"
- )
-
-
-class GHand(BalatroBaseModel):
- """Hand structure matching GHand in Lua types."""
-
- cards: list[GHandCards] = Field(
- default_factory=list, description="Array of cards in hand"
- )
- config: GHandConfig | None = Field(None, description="Hand configuration")
-
-
-class GJokersCardsConfig(BalatroBaseModel):
- """Joker card configuration matching GJokersCardsConfig in Lua types."""
-
- center: dict[str, Any] = Field(
- default_factory=dict, description="Center configuration for joker"
- )
-
-
-class GJokersCards(BalatroBaseModel):
- """Joker card matching GJokersCards in Lua types."""
-
- label: str = Field("", description="Display label of the joker")
- config: GJokersCardsConfig | None = Field(None, description="Joker configuration")
-
-
-class G(BalatroBaseModel):
- """Root game state response matching G in Lua types."""
-
- state: Any = Field(None, description="Current game state enum value")
- game: GGame | None = Field(
- None, description="Game information (null if not in game)"
- )
- hand: GHand | None = Field(
- None, description="Hand information (null if not available)"
- )
- jokers: list[GJokersCards] | dict[str, Any] = Field(
- default_factory=list, description="Jokers structure (can be list or dict)"
- )
-
- @field_validator("hand", mode="before")
- @classmethod
- def convert_empty_list_to_none_for_hand(cls, v):
- """Convert empty list to None for hand field."""
- return None if v == [] else v
-
- @property
- def state_enum(self) -> State | None:
- """Get the state as an enum value."""
- return State(self.state) if self.state is not None else None
-
-
-class ErrorResponse(BalatroBaseModel):
- """Model for API error responses matching Lua ErrorResponse."""
-
- error: str = Field(..., description="Error message")
- error_code: str = Field(..., description="Standardized error code")
- state: Any = Field(..., description="Current game state when error occurred")
- context: dict[str, Any] | None = Field(None, description="Additional error context")
-
-
-# =============================================================================
-# API Message Models
-# =============================================================================
-
-
-class APIRequest(BalatroBaseModel):
- """Model for API requests sent to the game."""
-
- model_config = ConfigDict(extra="forbid")
-
- name: str = Field(..., description="Function name to call")
- arguments: dict[str, Any] | list = Field(
- ..., description="Arguments for the function"
- )
-
-
-class APIResponse(BalatroBaseModel):
- """Model for API responses from the game."""
-
- model_config = ConfigDict(extra="allow")
-
-
-class JSONLLogEntry(BalatroBaseModel):
- """Model for JSONL log entries that record game actions."""
-
- timestamp_ms: int = Field(
- ...,
- description="Unix timestamp in milliseconds when the action occurred",
- )
- function: APIRequest = Field(
- ...,
- description="The game function that was called",
- )
- game_state: G = Field(
- ...,
- description="Complete game state before the function execution",
- )
diff --git a/src/balatrobot/py.typed b/src/balatrobot/py.typed
deleted file mode 100644
index e69de29..0000000
diff --git a/src/lua/api.lua b/src/lua/api.lua
deleted file mode 100644
index e66ed3d..0000000
--- a/src/lua/api.lua
+++ /dev/null
@@ -1,1515 +0,0 @@
-local socket = require("socket")
-local json = require("json")
-
--- Constants
-local SOCKET_TIMEOUT = 0
-
--- Error codes for standardized error handling
-local ERROR_CODES = {
- -- Protocol errors
- INVALID_JSON = "E001",
- MISSING_NAME = "E002",
- MISSING_ARGUMENTS = "E003",
- UNKNOWN_FUNCTION = "E004",
- INVALID_ARGUMENTS = "E005",
-
- -- Network errors
- SOCKET_CREATE_FAILED = "E006",
- SOCKET_BIND_FAILED = "E007",
- CONNECTION_FAILED = "E008",
-
- -- Validation errors
- INVALID_GAME_STATE = "E009",
- INVALID_PARAMETER = "E010",
- PARAMETER_OUT_OF_RANGE = "E011",
- MISSING_GAME_OBJECT = "E012",
-
- -- Game logic errors
- DECK_NOT_FOUND = "E013",
- INVALID_CARD_INDEX = "E014",
- NO_DISCARDS_LEFT = "E015",
- INVALID_ACTION = "E016",
-}
-
----Validates request parameters and returns validation result
----@param args table The arguments to validate
----@param required_fields string[] List of required field names
----@return boolean success True if validation passed
----@return string? error_message Error message if validation failed
----@return string? error_code Error code if validation failed
----@return table? context Additional context about the error
-local function validate_request(args, required_fields)
- if type(args) ~= "table" then
- return false, "Arguments must be a table", ERROR_CODES.INVALID_ARGUMENTS, { received_type = type(args) }
- end
-
- for _, field in ipairs(required_fields) do
- if args[field] == nil then
- return false, "Missing required field: " .. field, ERROR_CODES.INVALID_PARAMETER, { field = field }
- end
- end
-
- return true, nil, nil, nil
-end
-
-API = {}
-API.server_socket = nil
-API.client_socket = nil
-API.functions = {}
-API.pending_requests = {}
-
---------------------------------------------------------------------------------
--- Update Loop
---------------------------------------------------------------------------------
-
----Updates the API by processing TCP messages and pending requests
----@param _ number Delta time (not used)
----@diagnostic disable-next-line: duplicate-set-field
-function API.update(_)
- -- Create server socket if it doesn't exist
- if not API.server_socket then
- API.server_socket = socket.tcp()
- if not API.server_socket then
- sendErrorMessage("Failed to create TCP socket", "API")
- return
- end
-
- API.server_socket:settimeout(SOCKET_TIMEOUT)
- local host = G.BALATROBOT_HOST or "127.0.0.1"
- local port = G.BALATROBOT_PORT
- local success, err = API.server_socket:bind(host, tonumber(port) or 12346)
- if not success then
- sendErrorMessage("Failed to bind to port " .. port .. ": " .. tostring(err), "API")
- API.server_socket = nil
- return
- end
-
- API.server_socket:listen(1)
- sendDebugMessage("TCP server socket created on " .. host .. ":" .. port, "API")
- end
-
- -- Accept client connection if we don't have one
- if not API.client_socket then
- local client = API.server_socket:accept()
- if client then
- client:settimeout(SOCKET_TIMEOUT)
- API.client_socket = client
- sendDebugMessage("Client connected", "API")
- end
- end
-
- -- Process pending requests
- for key, request in pairs(API.pending_requests) do
- ---@cast request PendingRequest
- if request.condition() then
- request.action()
- API.pending_requests[key] = nil
- end
- end
-
- -- Parse received data and run the appropriate function
- if API.client_socket then
- local raw_data, err = API.client_socket:receive("*l")
- if raw_data then
- local ok, data = pcall(json.decode, raw_data)
- if not ok then
- API.send_error_response(
- "Invalid JSON: message could not be parsed. Send one JSON object per line with fields 'name' and 'arguments'",
- ERROR_CODES.INVALID_JSON,
- nil
- )
- return
- end
- ---@cast data APIRequest
- if data.name == nil then
- API.send_error_response(
- "Message must contain a name. Include a 'name' field, e.g. 'get_game_state'",
- ERROR_CODES.MISSING_NAME,
- nil
- )
- elseif data.arguments == nil then
- API.send_error_response(
- "Message must contain arguments. Include an 'arguments' object (use {} if no parameters)",
- ERROR_CODES.MISSING_ARGUMENTS,
- nil
- )
- else
- local func = API.functions[data.name]
- local args = data.arguments
- if func == nil then
- API.send_error_response(
- "Unknown function name. See docs for supported names. Common calls: 'get_game_state', 'start_run', 'shop', 'play_hand_or_discard'",
- ERROR_CODES.UNKNOWN_FUNCTION,
- { name = data.name }
- )
- elseif type(args) ~= "table" then
- API.send_error_response(
- "Arguments must be a table. The 'arguments' field must be a JSON object/table (use {} if empty)",
- ERROR_CODES.INVALID_ARGUMENTS,
- { received_type = type(args) }
- )
- else
- sendDebugMessage(data.name .. "(" .. json.encode(args) .. ")", "API")
- -- Trigger frame render if render-on-API mode is enabled
- if G.BALATROBOT_SHOULD_RENDER ~= nil then
- G.BALATROBOT_SHOULD_RENDER = true
- end
- func(args)
- end
- end
- elseif err == "closed" then
- sendDebugMessage("Client disconnected", "API")
- API.client_socket = nil
- elseif err ~= "timeout" then
- sendDebugMessage("TCP receive error: " .. tostring(err), "API")
- API.client_socket = nil
- end
- end
-end
-
----Sends a response back to the connected client
----@param response table The response data to send
-function API.send_response(response)
- if API.client_socket then
- local success, err = API.client_socket:send(json.encode(response) .. "\n")
- if not success then
- sendErrorMessage("Failed to send response: " .. tostring(err), "API")
- API.client_socket = nil
- end
- end
-end
-
----Sends an error response to the client with optional context
----@param message string The error message
----@param error_code string The standardized error code
----@param context? table Optional additional context about the error
-function API.send_error_response(message, error_code, context)
- sendErrorMessage(message, "API")
- ---@type ErrorResponse
- local response = {
- error = message,
- error_code = error_code,
- state = G.STATE,
- context = context,
- }
- API.send_response(response)
-end
-
----Initializes the API by setting up the update timer
-function API.init()
- -- Hook API.update into the existing love.update that's managed by settings.lua
- local original_update = love.update
- ---@diagnostic disable-next-line: duplicate-set-field
- love.update = function(dt)
- original_update(dt)
- API.update(dt)
- end
-
- sendInfoMessage("BalatrobotAPI initialized", "API")
-end
-
---------------------------------------------------------------------------------
--- API Functions
---------------------------------------------------------------------------------
-
----Gets the current game state
----@param _ table Arguments (not used)
-API.functions["get_game_state"] = function(_)
- ---@type PendingRequest
- API.pending_requests["get_game_state"] = {
- condition = utils.COMPLETION_CONDITIONS["get_game_state"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Navigates to the main menu.
----Call G.FUNCS.go_to_menu() to navigate to the main menu.
----@param _ table Arguments (not used)
-API.functions["go_to_menu"] = function(_)
- if G.STATE == G.STATES.MENU and G.MAIN_MENU_UI then
- sendDebugMessage("go_to_menu called but already in menu", "API")
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- return
- end
-
- G.FUNCS.go_to_menu({})
- API.pending_requests["go_to_menu"] = {
- condition = utils.COMPLETION_CONDITIONS["go_to_menu"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Starts a new game run with specified parameters
----Call G.FUNCS.start_run() to start a new game run with specified parameters.
----If log_path is provided, the run log will be saved to the specified full path (must include .jsonl extension), otherwise uses runs/timestamp.jsonl.
----@param args StartRunArgs The run configuration
-API.functions["start_run"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "deck" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Reset the game
- G.FUNCS.setup_run({ config = {} })
- G.FUNCS.exit_overlay_menu()
-
- -- Set the deck
- local deck_found = false
- for _, v in pairs(G.P_CENTER_POOLS.Back) do
- if v.name == args.deck then
- sendDebugMessage("Changing to deck: " .. v.name, "API")
- G.GAME.selected_back:change_to(v)
- G.GAME.viewed_back:change_to(v)
- deck_found = true
- break
- end
- end
- if not deck_found then
- API.send_error_response("Invalid deck name", ERROR_CODES.DECK_NOT_FOUND, { deck = args.deck })
- return
- end
-
- -- Set the challenge
- local challenge_obj = nil
- if args.challenge then
- for i = 1, #G.CHALLENGES do
- if G.CHALLENGES[i].name == args.challenge then
- challenge_obj = G.CHALLENGES[i]
- break
- end
- end
- end
- G.GAME.challenge_name = args.challenge
-
- -- Start the run
- G.FUNCS.start_run(nil, { stake = args.stake, seed = args.seed, challenge = challenge_obj, log_path = args.log_path })
-
- -- Defer sending response until the run has started
- ---@type PendingRequest
- API.pending_requests["start_run"] = {
- condition = utils.COMPLETION_CONDITIONS["start_run"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Skips or selects the current blind
----Call G.FUNCS.select_blind(button) or G.FUNCS.skip_blind(button)
----@param args BlindActionArgs The blind action to perform
-API.functions["skip_or_select_blind"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "action" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate current game state is appropriate for blind selection
- if G.STATE ~= G.STATES.BLIND_SELECT then
- API.send_error_response(
- "Cannot skip or select blind when not in blind selection. Wait until gamestate is BLIND_SELECT, or call 'shop' with action 'next_round' to advance out of the shop. Use 'get_game_state' to check the current state.",
- ERROR_CODES.INVALID_GAME_STATE,
- { current_state = G.STATE, expected_state = G.STATES.BLIND_SELECT }
- )
- return
- end
-
- -- Get the current blind pane
- local current_blind = G.GAME.blind_on_deck
- if not current_blind then
- API.send_error_response(
- "No blind currently on deck",
- ERROR_CODES.MISSING_GAME_OBJECT,
- { blind_on_deck = current_blind }
- )
- return
- end
- local blind_pane = G.blind_select_opts[string.lower(current_blind)]
-
- if G.GAME.blind_on_deck == "Boss" and args.action == "skip" then
- API.send_error_response(
- "Cannot skip Boss blind. Use select instead",
- ERROR_CODES.INVALID_PARAMETER,
- { current_state = G.STATE }
- )
- return
- end
-
- if args.action == "select" then
- local button = blind_pane:get_UIE_by_ID("select_blind_button")
- G.FUNCS.select_blind(button)
- ---@type PendingRequest
- API.pending_requests["skip_or_select_blind"] = {
- condition = utils.COMPLETION_CONDITIONS["skip_or_select_blind"]["select"],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- args = args,
- }
- elseif args.action == "skip" then
- local tag_element = blind_pane:get_UIE_by_ID("tag_" .. current_blind)
- local button = tag_element.children[2]
- G.FUNCS.skip_blind(button)
- ---@type PendingRequest
- API.pending_requests["skip_or_select_blind"] = {
- condition = utils.COMPLETION_CONDITIONS["skip_or_select_blind"]["skip"],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
- else
- API.send_error_response(
- "Invalid action for skip_or_select_blind",
- ERROR_CODES.INVALID_ACTION,
- { action = args.action, valid_actions = { "select", "skip" } }
- )
- return
- end
-end
-
----Plays selected cards or discards them
----Call G.FUNCS.play_cards_from_highlighted(play_button)
----or G.FUNCS.discard_cards_from_highlighted(discard_button)
----@param args HandActionArgs The hand action to perform
-API.functions["play_hand_or_discard"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "action", "cards" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate current game state is appropriate for playing hand or discarding
- if G.STATE ~= G.STATES.SELECTING_HAND then
- API.send_error_response(
- "Cannot play hand or discard when not in selecting hand state. First select the blind: call 'skip_or_select_blind' with action 'select' when selecting blind. Use 'get_game_state' to verify.",
- ERROR_CODES.INVALID_GAME_STATE,
- { current_state = G.STATE, expected_state = G.STATES.SELECTING_HAND }
- )
- return
- end
-
- -- Validate number of cards is between 1 and 5 (inclusive)
- if #args.cards < 1 or #args.cards > 5 then
- API.send_error_response(
- "Invalid number of cards",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { cards_count = #args.cards, valid_range = "1-5" }
- )
- return
- end
-
- if args.action == "discard" and G.GAME.current_round.discards_left == 0 then
- API.send_error_response(
- "No discards left to perform discard. Play a hand or advance the round; discards will reset next round.",
- ERROR_CODES.NO_DISCARDS_LEFT,
- { discards_left = G.GAME.current_round.discards_left }
- )
- return
- end
-
- -- adjust from 0-based to 1-based indexing
- for i, card_index in ipairs(args.cards) do
- args.cards[i] = card_index + 1
- end
-
- -- Check that all cards are selectable
- for _, card_index in ipairs(args.cards) do
- if not G.hand.cards[card_index] then
- API.send_error_response(
- "Invalid card index",
- ERROR_CODES.INVALID_CARD_INDEX,
- { card_index = card_index, hand_size = #G.hand.cards }
- )
- return
- end
- end
-
- -- Clear any existing highlights before selecting new cards to prevent state pollution
- G.hand:unhighlight_all()
-
- -- Select cards
- for _, card_index in ipairs(args.cards) do
- G.hand.cards[card_index]:click()
- end
-
- if args.action == "play_hand" then
- ---@diagnostic disable-next-line: undefined-field
- local play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot)
- G.FUNCS.play_cards_from_highlighted(play_button)
- elseif args.action == "discard" then
- ---@diagnostic disable-next-line: undefined-field
- local discard_button = UIBox:get_UIE_by_ID("discard_button", G.buttons.UIRoot)
- G.FUNCS.discard_cards_from_highlighted(discard_button)
- else
- API.send_error_response(
- "Invalid action for play_hand_or_discard",
- ERROR_CODES.INVALID_ACTION,
- { action = args.action, valid_actions = { "play_hand", "discard" } }
- )
- return
- end
-
- -- Defer sending response until the run has started
- ---@type PendingRequest
- API.pending_requests["play_hand_or_discard"] = {
- condition = utils.COMPLETION_CONDITIONS["play_hand_or_discard"][args.action],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Rearranges the hand based on the given card indices
----Call G.FUNCS.rearrange_hand(new_hand)
----@param args RearrangeHandArgs The card indices to rearrange the hand with
-API.functions["rearrange_hand"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "cards" })
-
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate current game state is appropriate for rearranging cards
- if G.STATE ~= G.STATES.SELECTING_HAND then
- API.send_error_response(
- "Cannot rearrange hand when not selecting hand. You can only rearrange while selecting your hand. You can check the current gamestate with 'get_game_state'.",
- ERROR_CODES.INVALID_GAME_STATE,
- { current_state = G.STATE, expected_state = G.STATES.SELECTING_HAND }
- )
- return
- end
-
- -- Validate number of cards is equal to the number of cards in hand
- if #args.cards ~= #G.hand.cards then
- API.send_error_response(
- "Invalid number of cards to rearrange",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { cards_count = #args.cards, valid_range = tostring(#G.hand.cards) }
- )
- return
- end
-
- -- Convert incoming indices from 0-based to 1-based
- for i, card_index in ipairs(args.cards) do
- args.cards[i] = card_index + 1
- end
-
- -- Create a new hand to swap card indices
- local new_hand = {}
- for _, old_index in ipairs(args.cards) do
- local card = G.hand.cards[old_index]
- if not card then
- API.send_error_response(
- "Card index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = old_index, max_index = #G.hand.cards }
- )
- return
- end
- table.insert(new_hand, card)
- end
-
- G.hand.cards = new_hand
-
- -- Update each card's order field so future sort('order') calls work correctly
- for i, card in ipairs(G.hand.cards) do
- card.config.card.order = i
- if card.config.center then
- card.config.center.order = i
- end
- end
-
- ---@type PendingRequest
- API.pending_requests["rearrange_hand"] = {
- condition = utils.COMPLETION_CONDITIONS["rearrange_hand"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Rearranges the jokers based on the given card indices
----Call G.FUNCS.rearrange_jokers(new_jokers)
----@param args RearrangeJokersArgs The card indices to rearrange the jokers with
-API.functions["rearrange_jokers"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "jokers" })
-
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate that jokers exist
- if not G.jokers or not G.jokers.cards or #G.jokers.cards == 0 then
- API.send_error_response(
- "No jokers available to rearrange",
- ERROR_CODES.MISSING_GAME_OBJECT,
- { jokers_available = false }
- )
- return
- end
-
- -- Validate number of jokers is equal to the number of jokers in the joker area
- if #args.jokers ~= #G.jokers.cards then
- API.send_error_response(
- "Invalid number of jokers to rearrange",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { jokers_count = #args.jokers, valid_range = tostring(#G.jokers.cards) }
- )
- return
- end
-
- -- Convert incoming indices from 0-based to 1-based
- for i, joker_index in ipairs(args.jokers) do
- args.jokers[i] = joker_index + 1
- end
-
- -- Create a new joker array to swap card indices
- local new_jokers = {}
- for _, old_index in ipairs(args.jokers) do
- local card = G.jokers.cards[old_index]
- if not card then
- API.send_error_response(
- "Joker index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = old_index, max_index = #G.jokers.cards }
- )
- return
- end
- table.insert(new_jokers, card)
- end
-
- G.jokers.cards = new_jokers
-
- -- Update each joker's order field so future sort('order') calls work correctly
- for i, card in ipairs(G.jokers.cards) do
- if card.ability then
- card.ability.order = i
- end
- if card.config and card.config.center then
- card.config.center.order = i
- end
- end
-
- ---@type PendingRequest
- API.pending_requests["rearrange_jokers"] = {
- condition = utils.COMPLETION_CONDITIONS["rearrange_jokers"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Rearranges the consumables based on the given card indices
----Call G.FUNCS.rearrange_consumables(new_consumables)
----@param args RearrangeConsumablesArgs The card indices to rearrange the consumables with
-API.functions["rearrange_consumables"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "consumables" })
-
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate that consumables exist
- if not G.consumeables or not G.consumeables.cards or #G.consumeables.cards == 0 then
- API.send_error_response(
- "No consumables available to rearrange",
- ERROR_CODES.MISSING_GAME_OBJECT,
- { consumables_available = false }
- )
- return
- end
-
- -- Validate number of consumables is equal to the number of consumables in the consumables area
- if #args.consumables ~= #G.consumeables.cards then
- API.send_error_response(
- "Invalid number of consumables to rearrange",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { consumables_count = #args.consumables, valid_range = tostring(#G.consumeables.cards) }
- )
- return
- end
-
- -- Convert incoming indices from 0-based to 1-based
- for i, consumable_index in ipairs(args.consumables) do
- args.consumables[i] = consumable_index + 1
- end
-
- -- Create a new consumables array to swap card indices
- local new_consumables = {}
- for _, old_index in ipairs(args.consumables) do
- local card = G.consumeables.cards[old_index]
- if not card then
- API.send_error_response(
- "Consumable index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = old_index, max_index = #G.consumeables.cards }
- )
- return
- end
- table.insert(new_consumables, card)
- end
-
- G.consumeables.cards = new_consumables
-
- -- Update each consumable's order field so future sort('order') calls work correctly
- for i, card in ipairs(G.consumeables.cards) do
- if card.ability then
- card.ability.order = i
- end
- if card.config and card.config.center then
- card.config.center.order = i
- end
- end
-
- ---@type PendingRequest
- API.pending_requests["rearrange_consumables"] = {
- condition = utils.COMPLETION_CONDITIONS["rearrange_consumables"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Cashes out from the current round to enter the shop
----Call G.FUNCS.cash_out() to cash out from the current round to enter the shop.
----@param _ table Arguments (not used)
-API.functions["cash_out"] = function(_)
- -- Validate current game state is appropriate for cash out
- if G.STATE ~= G.STATES.ROUND_EVAL then
- API.send_error_response(
- "Cannot cash out when not in round evaluation. Finish playing the hand to reach ROUND_EVAL first.",
- ERROR_CODES.INVALID_GAME_STATE,
- { current_state = G.STATE, expected_state = G.STATES.ROUND_EVAL }
- )
- return
- end
-
- G.FUNCS.cash_out({ config = {} })
- ---@type PendingRequest
- API.pending_requests["cash_out"] = {
- condition = utils.COMPLETION_CONDITIONS["cash_out"][""],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Selects an action for shop
----Call G.FUNCS.toggle_shop() to select an action for shop.
----@param args ShopActionArgs The shop action to perform
-API.functions["shop"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "action" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate current game state is appropriate for shop
- if G.STATE ~= G.STATES.SHOP then
- API.send_error_response(
- "Cannot select shop action when not in shop. Reach the shop by calling 'cash_out' during ROUND_EVAL, or finish a hand to enter evaluation.",
- ERROR_CODES.INVALID_GAME_STATE,
- { current_state = G.STATE, expected_state = G.STATES.SHOP }
- )
- return
- end
-
- local action = args.action
- if action == "next_round" then
- G.FUNCS.toggle_shop({})
- ---@type PendingRequest
- API.pending_requests["shop"] = {
- condition = utils.COMPLETION_CONDITIONS["shop"]["next_round"],
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
- elseif action == "buy_card" then
- -- Validate index argument
- if args.index == nil then
- API.send_error_response("Missing required field: index", ERROR_CODES.MISSING_ARGUMENTS, { field = "index" })
- return
- end
-
- -- Get card index (1-based) and shop area
- local card_pos = args.index + 1
- local area = G.shop_jokers
-
- -- Validate card index is in range
- if not area or not area.cards or not area.cards[card_pos] then
- API.send_error_response(
- "Card index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = args.index, valid_range = "0-" .. tostring(#area.cards - 1) }
- )
- return
- end
-
- -- Evaluate card
- local card = area.cards[card_pos]
-
- -- Check if the card can be afforded
- if card.cost > G.GAME.dollars then
- API.send_error_response(
- "Card is not affordable, choose a purchasable card or advance with 'shop' with action 'next_round'.",
- ERROR_CODES.INVALID_ACTION,
- { index = args.index, cost = card.cost, dollars = G.GAME.dollars }
- )
- return
- end
-
- -- Ensure card has an ability set (should be redundant)
- if not card.ability or not card.ability.set then
- API.send_error_response(
- "Card has no ability set, can't check consumable area",
- ERROR_CODES.INVALID_GAME_STATE,
- { index = args.index }
- )
- return
- end
-
- -- Ensure card area is not full
- if card.ability.set == "Joker" then
- -- Check for free joker slots
- if G.jokers and G.jokers.cards and G.jokers.card_limit and #G.jokers.cards >= G.jokers.card_limit then
- API.send_error_response(
- "Can't purchase joker card, joker slots are full",
- ERROR_CODES.INVALID_ACTION,
- { index = args.index }
- )
- return
- end
- elseif card.ability.set == "Planet" or card.ability.set == "Tarot" or card.ability.set == "Spectral" then
- -- Check for free consumable slots (typo is intentional, present in source)
- if
- G.consumeables
- and G.consumeables.cards
- and G.consumeables.card_limit
- and #G.consumeables.cards >= G.consumeables.card_limit
- then
- API.send_error_response(
- "Can't purchase consumable card, consumable slots are full",
- ERROR_CODES.INVALID_ACTION,
- { index = args.index }
- )
- end
- end
-
- -- Validate that some purchase button exists (should be a redundant check)
- local card_buy_button = card.children.buy_button and card.children.buy_button.definition
- if not card_buy_button then
- API.send_error_response("Card has no buy button", ERROR_CODES.INVALID_GAME_STATE, { index = args.index })
- return
- end
-
- -- activate the buy button using the UI element handler
- G.FUNCS.buy_from_shop(card_buy_button)
-
- -- send response once shop is updated
- ---@type PendingRequest
- API.pending_requests["shop"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["shop"]["buy_card"]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
- elseif action == "reroll" then
- -- Capture the state before rerolling for response validation
- local dollars_before = G.GAME.dollars
- local reroll_cost = G.GAME.current_round and G.GAME.current_round.reroll_cost or 0
-
- if dollars_before < reroll_cost then
- API.send_error_response(
- "Not enough dollars to reroll. You may use the 'shop' function with action 'next_round' to advance to the next round.",
- ERROR_CODES.INVALID_ACTION,
- { dollars = dollars_before, reroll_cost = reroll_cost }
- )
- return
- end
-
- -- no UI element required for reroll
- G.FUNCS.reroll_shop(nil)
-
- ---@type PendingRequest
- API.pending_requests["shop"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["shop"]["reroll"]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
- elseif action == "redeem_voucher" then
- -- Validate index argument
- if args.index == nil then
- API.send_error_response("Missing required field: index", ERROR_CODES.MISSING_ARGUMENTS, { field = "index" })
- return
- end
-
- local area = G.shop_vouchers
-
- if not area then
- API.send_error_response("Voucher area not found in shop", ERROR_CODES.INVALID_GAME_STATE, {})
- return
- end
-
- -- Get voucher index (1-based) and validate range
- local card_pos = args.index + 1
- if not area.cards or not area.cards[card_pos] then
- API.send_error_response(
- "Voucher index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = args.index, valid_range = "0-" .. tostring(#area.cards - 1) }
- )
- return
- end
-
- local card = area.cards[card_pos]
- -- Check affordability
- local dollars_before = G.GAME.dollars
- if dollars_before < card.cost then
- API.send_error_response(
- "Not enough dollars to redeem voucher",
- ERROR_CODES.INVALID_ACTION,
- { dollars = dollars_before, cost = card.cost }
- )
- return
- end
-
- -- Activate the voucher's purchase button to redeem
- local use_button = card.children.buy_button and card.children.buy_button.definition
- G.FUNCS.use_card(use_button)
-
- -- Wait until the shop is idle and dollars are updated (redeem is non-atomic)
- ---@type PendingRequest
- API.pending_requests["shop"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["shop"]["redeem_voucher"]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
- elseif action == "buy_and_use_card" then
- -- Validate index argument
- if args.index == nil then
- API.send_error_response("Missing required field: index", ERROR_CODES.MISSING_ARGUMENTS, { field = "index" })
- return
- end
-
- -- Get card index (1-based) and shop area (shop_jokers also holds consumables)
- local card_pos = args.index + 1
- local area = G.shop_jokers
-
- -- Validate card index is in range
- if not area or not area.cards or not area.cards[card_pos] then
- API.send_error_response(
- "Card index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = args.index, valid_range = "0-" .. tostring(#area.cards - 1) }
- )
- return
- end
-
- -- Evaluate card
- local card = area.cards[card_pos]
-
- -- Check if the card can be afforded
- if card.cost > G.GAME.dollars then
- API.send_error_response(
- "Card is not affordable. Choose a cheaper card or advance with 'shop' with action 'next_round'.",
- ERROR_CODES.INVALID_ACTION,
- { index = args.index, cost = card.cost, dollars = G.GAME.dollars }
- )
- return
- end
-
- -- Check if the consumable can be used
- if not card:can_use_consumeable() then
- API.send_error_response(
- "Consumable cannot be used at this time",
- ERROR_CODES.INVALID_ACTION,
- { index = args.index }
- )
- return
- end
-
- -- Locate the Buy & Use button definition
- local buy_and_use_button = card.children.buy_and_use_button and card.children.buy_and_use_button.definition
- if not buy_and_use_button then
- API.send_error_response(
- "Card has no buy_and_use button",
- ERROR_CODES.INVALID_GAME_STATE,
- { index = args.index, card_name = card.name }
- )
- return
- end
-
- -- Activate the buy_and_use button via the game's shop function
- G.FUNCS.buy_from_shop(buy_and_use_button)
-
- -- Defer sending response until the shop has processed the purchase and use
- ---@type PendingRequest
- API.pending_requests["shop"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["shop"]["buy_and_use_card"]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
- -- TODO: add other shop actions (open_pack)
- else
- API.send_error_response(
- "Invalid action for shop",
- ERROR_CODES.INVALID_ACTION,
- { action = action, valid_actions = { "next_round", "buy_card", "reroll" } }
- )
- return
- end
-end
-
----Sells a joker at the specified index
----Call G.FUNCS.sell_card() to sell the joker at the given index
----@param args SellJokerArgs The sell joker action arguments
-API.functions["sell_joker"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "index" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate that jokers exist
- if not G.jokers or not G.jokers.cards or #G.jokers.cards == 0 then
- API.send_error_response(
- "No jokers available to sell",
- ERROR_CODES.MISSING_GAME_OBJECT,
- { jokers_available = false }
- )
- return
- end
-
- -- Validate that index is a number
- if type(args.index) ~= "number" then
- API.send_error_response(
- "Invalid parameter type",
- ERROR_CODES.INVALID_PARAMETER,
- { parameter = "index", expected_type = "number" }
- )
- return
- end
-
- -- Convert from 0-based to 1-based indexing
- local joker_index = args.index + 1
-
- -- Validate joker index is in range
- if joker_index < 1 or joker_index > #G.jokers.cards then
- API.send_error_response(
- "Joker index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = args.index, jokers_count = #G.jokers.cards }
- )
- return
- end
-
- -- Get the joker card
- local joker_card = G.jokers.cards[joker_index]
- if not joker_card then
- API.send_error_response("Joker not found at index", ERROR_CODES.MISSING_GAME_OBJECT, { index = args.index })
- return
- end
-
- -- Check if the joker can be sold
- if not joker_card:can_sell_card() then
- API.send_error_response("Joker cannot be sold at this time", ERROR_CODES.INVALID_ACTION, { index = args.index })
- return
- end
-
- -- Create a mock UI element to call G.FUNCS.sell_card
- local mock_element = {
- config = {
- ref_table = joker_card,
- },
- }
-
- -- Call G.FUNCS.sell_card to sell the joker
- G.FUNCS.sell_card(mock_element)
-
- ---@type PendingRequest
- API.pending_requests["sell_joker"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["sell_joker"][""]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Uses a consumable at the specified index
----Call G.FUNCS.use_card() to use the consumable at the given index
----@param args UseConsumableArgs The use consumable action arguments
-API.functions["use_consumable"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "index" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate that consumables exist
- if not G.consumeables or not G.consumeables.cards or #G.consumeables.cards == 0 then
- API.send_error_response(
- "No consumables available to use",
- ERROR_CODES.MISSING_GAME_OBJECT,
- { consumables_available = false }
- )
- return
- end
-
- -- Validate that index is a number and an integer
- if type(args.index) ~= "number" then
- API.send_error_response(
- "Invalid parameter type",
- ERROR_CODES.INVALID_PARAMETER,
- { parameter = "index", expected_type = "number" }
- )
- return
- end
-
- -- Validate that index is an integer
- if args.index % 1 ~= 0 then
- API.send_error_response(
- "Invalid parameter type",
- ERROR_CODES.INVALID_PARAMETER,
- { parameter = "index", expected_type = "integer" }
- )
- return
- end
-
- -- Convert from 0-based to 1-based indexing
- local consumable_index = args.index + 1
-
- -- Validate consumable index is in range
- if consumable_index < 1 or consumable_index > #G.consumeables.cards then
- API.send_error_response(
- "Consumable index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = args.index, consumables_count = #G.consumeables.cards }
- )
- return
- end
-
- -- Get the consumable card
- local consumable_card = G.consumeables.cards[consumable_index]
- if not consumable_card then
- API.send_error_response("Consumable not found at index", ERROR_CODES.MISSING_GAME_OBJECT, { index = args.index })
- return
- end
-
- -- Get consumable's card requirements
- local max_cards = consumable_card.ability.consumeable.max_highlighted
- local min_cards = consumable_card.ability.consumeable.min_highlighted or 1
- local consumable_name = consumable_card.ability.name or "Unknown"
- local required_cards = max_cards ~= nil
-
- -- Validate cards parameter type if provided
- if args.cards ~= nil then
- if type(args.cards) ~= "table" then
- API.send_error_response(
- "Invalid parameter type for cards. Expected array, got " .. tostring(type(args.cards)),
- ERROR_CODES.INVALID_PARAMETER,
- { parameter = "cards", expected_type = "array" }
- )
- return
- end
-
- -- Validate all elements are numbers
- for i, card_index in ipairs(args.cards) do
- if type(card_index) ~= "number" then
- API.send_error_response(
- "Invalid card index type. Expected number, got " .. tostring(type(card_index)),
- ERROR_CODES.INVALID_PARAMETER,
- { index = i - 1, value_type = type(card_index) }
- )
- return
- end
- end
- end
-
- -- The consumable does not require any card selection
- if not required_cards and args.cards then
- if #args.cards > 0 then
- API.send_error_response(
- "The selected consumable does not require card selection. Cards array must be empty or no cards array at all.",
- ERROR_CODES.INVALID_PARAMETER,
- { consumable_name = consumable_name }
- )
- return
- end
- -- If cards=[] (empty), that's fine, just skip the card selection logic
- end
-
- if required_cards then
- if G.STATE ~= G.STATES.SELECTING_HAND then
- API.send_error_response(
- "Cannot use consumable with cards when there are no cards to select. Expects SELECTING_HAND state.",
- ERROR_CODES.INVALID_GAME_STATE,
- { current_state = G.STATE, required_state = G.STATES.SELECTING_HAND }
- )
- return
- end
-
- local num_cards = args.cards == nil and 0 or #args.cards
- if num_cards < min_cards or num_cards > max_cards then
- local range_msg = min_cards == max_cards and ("exactly " .. min_cards) or (min_cards .. "-" .. max_cards)
- API.send_error_response(
- "Invalid number of cards for "
- .. consumable_name
- .. ". Expected "
- .. range_msg
- .. ", got "
- .. tostring(num_cards),
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { cards_count = num_cards, min_cards = min_cards, max_cards = max_cards, consumable_name = consumable_name }
- )
- return
- end
-
- -- Convert from 0-based to 1-based indexing
- for i, card_index in ipairs(args.cards) do
- args.cards[i] = card_index + 1
- end
-
- -- Check that all cards exist and are selectable
- for _, card_index in ipairs(args.cards) do
- if not G.hand or not G.hand.cards or not G.hand.cards[card_index] then
- API.send_error_response(
- "Invalid card index",
- ERROR_CODES.INVALID_CARD_INDEX,
- { card_index = card_index - 1, hand_size = G.hand and G.hand.cards and #G.hand.cards or 0 }
- )
- return
- end
- end
-
- -- Clear any existing highlights before selecting new cards
- if G.hand then
- G.hand:unhighlight_all()
- end
-
- -- Select cards for the consumable to target
- for _, card_index in ipairs(args.cards) do
- G.hand.cards[card_index]:click()
- end
- end
-
- -- Check if the consumable can be used
- if not consumable_card:can_use_consumeable() then
- local error_msg = "Consumable cannot be used for unknown reason."
- API.send_error_response(error_msg, ERROR_CODES.INVALID_ACTION, {})
- return
- end
-
- -- Create a mock UI element to call G.FUNCS.use_card
- local mock_element = {
- config = {
- ref_table = consumable_card,
- },
- }
-
- -- Call G.FUNCS.use_card to use the consumable
- G.FUNCS.use_card(mock_element)
-
- ---@type PendingRequest
- API.pending_requests["use_consumable"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["use_consumable"][""]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Sells a consumable at the specified index
----Call G.FUNCS.sell_card() to sell the consumable at the given index
----@param args SellConsumableArgs The sell consumable action arguments
-API.functions["sell_consumable"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "index" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Validate that consumables exist
- if not G.consumeables or not G.consumeables.cards or #G.consumeables.cards == 0 then
- API.send_error_response(
- "No consumables available to sell",
- ERROR_CODES.MISSING_GAME_OBJECT,
- { consumables_available = false }
- )
- return
- end
-
- -- Validate that index is a number
- if type(args.index) ~= "number" then
- API.send_error_response(
- "Invalid parameter type",
- ERROR_CODES.INVALID_PARAMETER,
- { parameter = "index", expected_type = "number" }
- )
- return
- end
-
- -- Convert from 0-based to 1-based indexing
- local consumable_index = args.index + 1
-
- -- Validate consumable index is in range
- if consumable_index < 1 or consumable_index > #G.consumeables.cards then
- API.send_error_response(
- "Consumable index out of range",
- ERROR_CODES.PARAMETER_OUT_OF_RANGE,
- { index = args.index, consumables_count = #G.consumeables.cards }
- )
- return
- end
-
- -- Get the consumable card
- local consumable_card = G.consumeables.cards[consumable_index]
- if not consumable_card then
- API.send_error_response("Consumable not found at index", ERROR_CODES.MISSING_GAME_OBJECT, { index = args.index })
- return
- end
-
- -- Check if the consumable can be sold
- if not consumable_card:can_sell_card() then
- API.send_error_response(
- "Consumable cannot be sold at this time",
- ERROR_CODES.INVALID_ACTION,
- { index = args.index }
- )
- return
- end
-
- -- Create a mock UI element to call G.FUNCS.sell_card
- local mock_element = {
- config = {
- ref_table = consumable_card,
- },
- }
-
- -- Call G.FUNCS.sell_card to sell the consumable
- G.FUNCS.sell_card(mock_element)
-
- ---@type PendingRequest
- API.pending_requests["sell_consumable"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["sell_consumable"][""]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
---------------------------------------------------------------------------------
--- Checkpoint System
---------------------------------------------------------------------------------
-
----Gets the current save file location and profile information
----Note that this will return a non-existent windows path linux, see normalization in client.py
----@param _ table Arguments (not used)
-API.functions["get_save_info"] = function(_)
- local save_info = {
- profile_path = G.SETTINGS and G.SETTINGS.profile or nil,
- save_directory = love and love.filesystem and love.filesystem.getSaveDirectory() or nil,
- has_active_run = G.GAME and G.GAME.round and true or false,
- }
-
- -- Construct full save file path
- if save_info.save_directory and save_info.profile_path then
- -- Full OS path to the save file
- save_info.save_file_path = save_info.save_directory .. "/" .. save_info.profile_path .. "/save.jkr"
- elseif save_info.profile_path then
- -- Fallback to relative path if we can't get save directory
- save_info.save_file_path = save_info.profile_path .. "/save.jkr"
- else
- save_info.save_file_path = nil
- end
-
- -- Check if save file exists (using the relative path for Love2D filesystem)
- if save_info.profile_path then
- local relative_path = save_info.profile_path .. "/save.jkr"
- local save_data = get_compressed(relative_path)
- save_info.save_exists = save_data ~= nil
- else
- save_info.save_exists = false
- end
-
- API.send_response(save_info)
-end
-
----Loads a save file directly and starts a run from it
----This allows loading a specific save state without requiring a game restart
----@param args LoadSaveArgs Arguments containing the save file path
-API.functions["load_save"] = function(args)
- -- Validate required parameters
- local success, error_message, error_code, context = validate_request(args, { "save_path" })
- if not success then
- ---@cast error_message string
- ---@cast error_code string
- API.send_error_response(error_message, error_code, context)
- return
- end
-
- -- Load the save file using get_compressed
- local save_data = get_compressed(args.save_path)
- if not save_data then
- API.send_error_response("Failed to load save file", ERROR_CODES.MISSING_GAME_OBJECT, { save_path = args.save_path })
- return
- end
-
- -- Unpack the save data
- local success, save_table = pcall(STR_UNPACK, save_data)
- if not success then
- API.send_error_response(
- "Failed to parse save file",
- ERROR_CODES.INVALID_PARAMETER,
- { save_path = args.save_path, error = tostring(save_table) }
- )
- return
- end
-
- -- Delete current run if exists
- G:delete_run()
-
- -- Start run with the loaded save
- G:start_run({ savetext = save_table })
-
- -- Wait for run to start
- ---@type PendingRequest
- API.pending_requests["load_save"] = {
- condition = function()
- return utils.COMPLETION_CONDITIONS["load_save"][""]()
- end,
- action = function()
- local game_state = utils.get_game_state()
- API.send_response(game_state)
- end,
- }
-end
-
----Takes a screenshot of the current game state and saves it to LÖVE's write directory
----Call love.graphics.captureScreenshot() to capture the current frame as compressed PNG image
----Returns the path where the screenshot was saved
-API.functions["screenshot"] = function(args)
- -- Track screenshot completion
- local screenshot_completed = false
- local screenshot_error = nil
- local screenshot_filename = nil
-
- -- Generate unique filename within LÖVE's write directory
- local timestamp = tostring(love.timer.getTime()):gsub("%.", "")
- screenshot_filename = "screenshot_" .. timestamp .. ".png"
-
- -- Capture screenshot using LÖVE 11.0+ API
- love.graphics.captureScreenshot(function(imagedata)
- if imagedata then
- -- Save the screenshot as PNG to LÖVE's write directory
- local png_success, png_err = pcall(function()
- imagedata:encode("png", screenshot_filename)
- end)
-
- if png_success then
- screenshot_completed = true
- sendDebugMessage("Screenshot saved: " .. screenshot_filename, "API")
- else
- screenshot_error = "Failed to save PNG screenshot: " .. tostring(png_err)
- sendErrorMessage(screenshot_error, "API")
- end
- else
- screenshot_error = "Failed to capture screenshot"
- sendErrorMessage(screenshot_error, "API")
- end
- end)
-
- -- Defer sending response until the screenshot operation completes
- ---@type PendingRequest
- API.pending_requests["screenshot"] = {
- condition = function()
- return screenshot_completed or screenshot_error ~= nil
- end,
- action = function()
- if screenshot_error then
- API.send_error_response(screenshot_error, ERROR_CODES.INVALID_ACTION, {})
- else
- -- Return screenshot path
- local screenshot_response = {
- path = love.filesystem.getSaveDirectory() .. "/" .. screenshot_filename,
- }
- API.send_response(screenshot_response)
- end
- end,
- }
-end
-
-return API
diff --git a/src/lua/core/dispatcher.lua b/src/lua/core/dispatcher.lua
new file mode 100644
index 0000000..08dfe4e
--- /dev/null
+++ b/src/lua/core/dispatcher.lua
@@ -0,0 +1,206 @@
+--[[
+ Request Dispatcher - Routes API requests to endpoints with 4-tier validation:
+ 1. Protocol (method field) 2. Schema (via Validator)
+ 3. Game state 4. Execution
+]]
+
+---@type Validator
+local Validator = assert(SMODS.load_file("src/lua/core/validator.lua"))()
+
+---@type table?
+local STATE_NAME_CACHE = nil
+
+---@param state_value integer
+---@return string
+local function get_state_name(state_value)
+ if not STATE_NAME_CACHE then
+ STATE_NAME_CACHE = {}
+ if G and G.STATES then
+ for name, value in pairs(G.STATES) do
+ STATE_NAME_CACHE[value] = name
+ end
+ end
+ end
+ return STATE_NAME_CACHE[state_value] or tostring(state_value)
+end
+
+---@type Dispatcher
+BB_DISPATCHER = {
+ endpoints = {},
+ Server = nil,
+}
+
+---@param endpoint Endpoint
+---@return boolean success
+---@return string? error_message
+local function validate_endpoint_structure(endpoint)
+ if not endpoint.name or type(endpoint.name) ~= "string" then
+ return false, "Endpoint missing 'name' field (string)"
+ end
+ if not endpoint.description or type(endpoint.description) ~= "string" then
+ return false, "Endpoint '" .. endpoint.name .. "' missing 'description' field (string)"
+ end
+ if not endpoint.schema or type(endpoint.schema) ~= "table" then
+ return false, "Endpoint '" .. endpoint.name .. "' missing 'schema' field (table)"
+ end
+ if not endpoint.execute or type(endpoint.execute) ~= "function" then
+ return false, "Endpoint '" .. endpoint.name .. "' missing 'execute' field (function)"
+ end
+ if endpoint.requires_state ~= nil and type(endpoint.requires_state) ~= "table" then
+ return false, "Endpoint '" .. endpoint.name .. "' 'requires_state' must be nil or table"
+ end
+ for field_name, field_schema in pairs(endpoint.schema) do
+ if type(field_schema) ~= "table" then
+ return false, "Endpoint '" .. endpoint.name .. "' schema field '" .. field_name .. "' must be a table"
+ end
+ if not field_schema.type then
+ return false, "Endpoint '" .. endpoint.name .. "' schema field '" .. field_name .. "' missing 'type' definition"
+ end
+ end
+ return true
+end
+
+---@param endpoint Endpoint
+---@return boolean success
+---@return string? error_message
+function BB_DISPATCHER.register(endpoint)
+ local valid, err = validate_endpoint_structure(endpoint)
+ if not valid then
+ return false, err
+ end
+ if BB_DISPATCHER.endpoints[endpoint.name] then
+ return false, "Endpoint '" .. endpoint.name .. "' is already registered"
+ end
+ BB_DISPATCHER.endpoints[endpoint.name] = endpoint
+ sendDebugMessage("Registered endpoint: " .. endpoint.name, "BB.DISPATCHER")
+ return true
+end
+
+---@param endpoint_files string[]
+---@return boolean success
+---@return string? error_message
+function BB_DISPATCHER.load_endpoints(endpoint_files)
+ local loaded_count = 0
+ for _, filepath in ipairs(endpoint_files) do
+ sendDebugMessage("Loading endpoint: " .. filepath, "BB.DISPATCHER")
+ local success, endpoint = pcall(function()
+ return assert(SMODS.load_file(filepath))()
+ end)
+ if not success then
+ return false, "Failed to load endpoint '" .. filepath .. "': " .. tostring(endpoint)
+ end
+ local reg_success, reg_err = BB_DISPATCHER.register(endpoint)
+ if not reg_success then
+ return false, "Failed to register endpoint '" .. filepath .. "': " .. reg_err
+ end
+ loaded_count = loaded_count + 1
+ end
+ sendDebugMessage("Loaded " .. loaded_count .. " endpoint(s)", "BB.DISPATCHER")
+ return true
+end
+
+---@param server_module table
+---@param endpoint_files string[]?
+---@return boolean success
+function BB_DISPATCHER.init(server_module, endpoint_files)
+ BB_DISPATCHER.Server = server_module
+ endpoint_files = endpoint_files or { "src/lua/endpoints/health.lua" }
+ local success, err = BB_DISPATCHER.load_endpoints(endpoint_files)
+ if not success then
+ sendErrorMessage("Dispatcher initialization failed: " .. err, "BB.DISPATCHER")
+ return false
+ end
+ sendDebugMessage("Dispatcher initialized successfully", "BB.DISPATCHER")
+ return true
+end
+
+---@param message string
+---@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")
+ return
+ end
+ BB_DISPATCHER.Server.send_response({
+ message = message,
+ name = error_code,
+ })
+end
+
+---@param request Request.Server
+function BB_DISPATCHER.dispatch(request)
+ -- TIER 1: Protocol Validation (jsonrpc version checked in server.receive())
+ if not request.method or type(request.method) ~= "string" then
+ BB_DISPATCHER.send_error("Request missing 'method' field", BB_ERROR_NAMES.BAD_REQUEST)
+ return
+ end
+
+ -- Handle rpc.discover (OpenRPC Service Discovery)
+ if request.method == "rpc.discover" then
+ if BB_DISPATCHER.Server and BB_DISPATCHER.Server.openrpc_spec then
+ local json = require("json")
+ local success, spec = pcall(json.decode, BB_DISPATCHER.Server.openrpc_spec)
+ if success then
+ BB_DISPATCHER.Server.send_response(spec)
+ else
+ BB_DISPATCHER.send_error("Failed to parse OpenRPC spec", BB_ERROR_NAMES.INTERNAL_ERROR)
+ end
+ else
+ BB_DISPATCHER.send_error("OpenRPC spec not available", BB_ERROR_NAMES.INTERNAL_ERROR)
+ end
+ return
+ end
+
+ local params = request.params or {}
+ local endpoint = BB_DISPATCHER.endpoints[request.method]
+ if not endpoint then
+ BB_DISPATCHER.send_error("Unknown method: " .. request.method, BB_ERROR_NAMES.BAD_REQUEST)
+ return
+ end
+ sendDebugMessage("Dispatching: " .. request.method, "BB.DISPATCHER")
+
+ -- TIER 2: Schema Validation
+ local valid, err_msg, err_code = Validator.validate(params, endpoint.schema)
+ if not valid then
+ BB_DISPATCHER.send_error(err_msg or "Validation failed", err_code or BB_ERROR_NAMES.BAD_REQUEST)
+ return
+ end
+
+ -- TIER 3: Game State Validation
+ if endpoint.requires_state then
+ local current_state = G and G.STATE or "UNKNOWN"
+ local state_valid = false
+ for _, required_state in ipairs(endpoint.requires_state) do
+ if current_state == required_state then
+ state_valid = true
+ break
+ end
+ end
+ if not state_valid then
+ local state_names = {}
+ for _, state in ipairs(endpoint.requires_state) do
+ table.insert(state_names, get_state_name(state))
+ end
+ BB_DISPATCHER.send_error(
+ "Method '" .. request.method .. "' requires one of these states: " .. table.concat(state_names, ", "),
+ BB_ERROR_NAMES.INVALID_STATE
+ )
+ return
+ end
+ end
+
+ -- TIER 4: Execute Endpoint
+ local function send_response(response)
+ if BB_DISPATCHER.Server then
+ BB_DISPATCHER.Server.send_response(response)
+ else
+ sendDebugMessage("Cannot send response - Server not initialized", "BB.DISPATCHER")
+ end
+ end
+ local exec_success, exec_error = pcall(function()
+ endpoint.execute(params, send_response)
+ end)
+ if not exec_success then
+ BB_DISPATCHER.send_error(tostring(exec_error), BB_ERROR_NAMES.INTERNAL_ERROR)
+ end
+end
diff --git a/src/lua/core/server.lua b/src/lua/core/server.lua
new file mode 100644
index 0000000..3e2c92b
--- /dev/null
+++ b/src/lua/core/server.lua
@@ -0,0 +1,468 @@
+--[[
+ HTTP Server - Single-client, non-blocking HTTP/1.1 server on port 12346.
+ JSON-RPC 2.0 protocol over HTTP POST to "/" only.
+]]
+
+local socket = require("socket")
+local json = require("json")
+
+-- ============================================================================
+-- Constants
+-- ============================================================================
+
+local MAX_BODY_SIZE = 65536 -- 64KB max request body
+local RECV_CHUNK_SIZE = 8192 -- Read buffer size
+
+-- ============================================================================
+-- HTTP Parsing
+-- ============================================================================
+
+--- Parse HTTP request line (e.g., "POST / HTTP/1.1")
+---@param line string
+---@return table|nil request {method, path, version} or nil on error
+local function parse_request_line(line)
+ local method, path, version = line:match("^(%u+)%s+(%S+)%s+HTTP/(%d%.%d)")
+ if not method then
+ return nil
+ end
+ return { method = method, path = path, version = version }
+end
+
+--- Parse HTTP headers from header block
+---@param header_lines string[] Array of header lines
+---@return table headers {["header-name"] = "value", ...} (lowercase keys)
+local function parse_headers(header_lines)
+ local headers = {}
+ for _, line in ipairs(header_lines) do
+ local name, value = line:match("^([^:]+):%s*(.*)$")
+ if name then
+ headers[name:lower()] = value
+ end
+ end
+ return headers
+end
+
+--- Format HTTP response with standard headers
+---@param status_code number HTTP status code
+---@param status_text string HTTP status text
+---@param body string Response body
+---@param extra_headers string[]|nil Additional headers
+---@return string HTTP response
+local function format_http_response(status_code, status_text, body, extra_headers)
+ local headers = {
+ "HTTP/1.1 " .. status_code .. " " .. status_text,
+ "Content-Type: application/json",
+ "Content-Length: " .. #body,
+ "Connection: close",
+ }
+
+ -- Add any extra headers
+ if extra_headers then
+ for _, h in ipairs(extra_headers) do
+ table.insert(headers, h)
+ end
+ end
+
+ return table.concat(headers, "\r\n") .. "\r\n\r\n" .. body
+end
+
+-- ============================================================================
+-- Server Module
+-- ============================================================================
+
+---@type Server
+BB_SERVER = {
+ host = BB_SETTINGS.host,
+ port = BB_SETTINGS.port,
+ server_socket = nil,
+ client_socket = nil,
+ current_request_id = nil,
+ client_state = nil,
+ openrpc_spec = nil,
+}
+
+--- Create fresh client state for HTTP parsing
+---@return table client_state
+local function new_client_state()
+ return {
+ buffer = "",
+ }
+end
+
+--- Initialize server socket and load OpenRPC spec
+---@return boolean success
+function BB_SERVER.init()
+ -- Create and bind server socket
+ local server, err = socket.tcp()
+ if not server then
+ sendErrorMessage("Failed to create socket: " .. tostring(err), "BB.SERVER")
+ return false
+ end
+
+ -- Allow address reuse for faster restarts
+ server:setoption("reuseaddr", true) ---@diagnostic disable-line: undefined-field
+
+ local success, bind_err = server:bind(BB_SERVER.host, BB_SERVER.port)
+ if not success then
+ sendErrorMessage("Failed to bind to port " .. BB_SERVER.port .. ": " .. tostring(bind_err), "BB.SERVER")
+ return false
+ end
+
+ local listen_success, listen_err = server:listen(1)
+ if not listen_success then
+ sendErrorMessage("Failed to listen: " .. tostring(listen_err), "BB.SERVER")
+ return false
+ end
+
+ server:settimeout(0)
+ BB_SERVER.server_socket = server
+
+ -- Load OpenRPC spec file from mod directory
+ local spec_path = SMODS.current_mod.path .. "src/lua/utils/openrpc.json"
+ local spec_file = io.open(spec_path, "r")
+ if spec_file then
+ BB_SERVER.openrpc_spec = spec_file:read("*a")
+ spec_file:close()
+ sendDebugMessage("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")
+ return true
+end
+
+--- Accept new client connection
+---@return boolean accepted
+function BB_SERVER.accept()
+ if not BB_SERVER.server_socket then
+ return false
+ end
+
+ local client, err = BB_SERVER.server_socket:accept()
+ if err then
+ if err ~= "timeout" then
+ sendErrorMessage("Failed to accept client: " .. tostring(err), "BB.SERVER")
+ end
+ return false
+ end
+
+ if client then
+ -- Close existing client if any
+ if BB_SERVER.client_socket then
+ BB_SERVER.client_socket:close()
+ BB_SERVER.client_socket = nil
+ BB_SERVER.client_state = nil
+ end
+
+ client:settimeout(0)
+ BB_SERVER.client_socket = client
+ BB_SERVER.client_state = new_client_state()
+ sendDebugMessage("Client connected", "BB.SERVER")
+ return true
+ end
+
+ return false
+end
+
+--- Close current client connection
+local function close_client()
+ if BB_SERVER.client_socket then
+ BB_SERVER.client_socket:close()
+ BB_SERVER.client_socket = nil
+ BB_SERVER.client_state = nil
+ end
+end
+
+--- Try to parse a complete HTTP request from the buffer
+---@return table|nil request Parsed request or nil if incomplete
+local function try_parse_http()
+ local state = BB_SERVER.client_state
+ if not state then
+ return nil
+ end
+
+ local buffer = state.buffer
+
+ -- Find end of headers (double CRLF)
+ local header_end = buffer:find("\r\n\r\n")
+ if not header_end then
+ return nil -- Incomplete, wait for more data
+ end
+
+ -- Split header section into lines
+ local header_section = buffer:sub(1, header_end - 1)
+ local lines = {}
+ for line in header_section:gmatch("[^\r\n]+") do
+ table.insert(lines, line)
+ end
+
+ if #lines == 0 then
+ return { error = "Empty request" }
+ end
+
+ -- Parse request line
+ local request = parse_request_line(lines[1])
+ if not request then
+ return { error = "Invalid request line" }
+ end
+
+ -- Parse headers
+ local header_lines = {}
+ for i = 2, #lines do
+ table.insert(header_lines, lines[i])
+ end
+ request.headers = parse_headers(header_lines)
+
+ -- Handle body for POST requests
+ local body_start = header_end + 4
+ if request.method == "POST" then
+ local content_length = tonumber(request.headers["content-length"] or 0)
+
+ -- Validate content length
+ if content_length > MAX_BODY_SIZE then
+ return { error = "Request body too large" }
+ end
+
+ -- Check if we have the complete body
+ local body_available = #buffer - body_start + 1
+ if body_available < content_length then
+ return nil -- Incomplete body, wait for more data
+ end
+
+ request.body = buffer:sub(body_start, body_start + content_length - 1)
+ else
+ request.body = ""
+ end
+
+ return request
+end
+
+--- Send raw HTTP response to client
+---@param response_str string Complete HTTP response
+---@return boolean success
+local function send_raw(response_str)
+ if not BB_SERVER.client_socket then
+ return false
+ end
+
+ local _, err = BB_SERVER.client_socket:send(response_str)
+ if err then
+ sendDebugMessage("Failed to send response: " .. err, "BB.SERVER")
+ return false
+ end
+ return true
+end
+
+--- Send HTTP error response
+---@param status_code number HTTP status code
+---@param message string Error message
+local function send_http_error(status_code, message)
+ local status_texts = {
+ [400] = "Bad Request",
+ [404] = "Not Found",
+ [405] = "Method Not Allowed",
+ [500] = "Internal Server Error",
+ }
+
+ local status_text = status_texts[status_code] or "Error"
+ local error_name = status_code == 500 and BB_ERROR_NAMES.INTERNAL_ERROR or BB_ERROR_NAMES.BAD_REQUEST
+
+ local body = json.encode({
+ jsonrpc = "2.0",
+ error = {
+ code = BB_ERROR_CODES[error_name],
+ message = message,
+ data = { name = error_name },
+ },
+ id = BB_SERVER.current_request_id,
+ })
+
+ send_raw(format_http_response(status_code, status_text, body))
+ close_client()
+end
+
+--- Handle JSON-RPC request
+---@param body string Request body (JSON)
+---@param dispatcher Dispatcher
+local function handle_jsonrpc(body, dispatcher)
+ -- Validate JSON
+ local success, parsed = pcall(json.decode, body)
+ if not success or type(parsed) ~= "table" then
+ BB_SERVER.current_request_id = nil
+ BB_SERVER.send_response({
+ message = "Invalid JSON in request body",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate JSON-RPC version
+ if parsed.jsonrpc ~= "2.0" then
+ BB_SERVER.current_request_id = parsed.id
+ BB_SERVER.send_response({
+ message = "Invalid JSON-RPC version: expected '2.0'",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate request ID (must be non-null integer or string)
+ if parsed.id == nil then
+ BB_SERVER.current_request_id = nil
+ BB_SERVER.send_response({
+ message = "Invalid Request: 'id' field is required",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ local id_type = type(parsed.id)
+ if id_type ~= "number" and id_type ~= "string" then
+ BB_SERVER.current_request_id = nil
+ BB_SERVER.send_response({
+ message = "Invalid Request: 'id' must be an integer or string",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ if id_type == "number" and parsed.id ~= math.floor(parsed.id) then
+ BB_SERVER.current_request_id = nil
+ BB_SERVER.send_response({
+ message = "Invalid Request: 'id' must be an integer, not a float",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ BB_SERVER.current_request_id = parsed.id
+
+ -- Dispatch to endpoint
+ if dispatcher and dispatcher.dispatch then
+ dispatcher.dispatch(parsed)
+ else
+ BB_SERVER.send_response({
+ message = "Server not fully initialized (dispatcher not ready)",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ end
+end
+
+--- Handle parsed HTTP request
+---@param request table Parsed HTTP request
+---@param dispatcher Dispatcher
+local function handle_http_request(request, dispatcher)
+ -- Handle parse errors
+ if request.error then
+ send_http_error(400, request.error)
+ return
+ end
+
+ local method = request.method
+ local path = request.path
+
+ -- Only POST method is allowed
+ if method ~= "POST" then
+ send_http_error(405, "Method not allowed. Use POST for JSON-RPC requests")
+ return
+ end
+
+ -- Only root path is allowed
+ if path ~= "/" then
+ send_http_error(404, "Not found. Use POST to '/' for JSON-RPC requests")
+ return
+ end
+
+ handle_jsonrpc(request.body, dispatcher)
+end
+
+--- Send JSON-RPC response to client (called by endpoints)
+---@param response Response.Endpoint
+---@return boolean success
+function BB_SERVER.send_response(response)
+ if not BB_SERVER.client_socket then
+ return false
+ end
+
+ local wrapped
+ if response.message then
+ -- Error response
+ local error_name = response.name or BB_ERROR_NAMES.INTERNAL_ERROR
+ local error_code = BB_ERROR_CODES[error_name] or BB_ERROR_CODES.INTERNAL_ERROR
+ wrapped = {
+ jsonrpc = "2.0",
+ error = {
+ code = error_code,
+ message = response.message,
+ data = { name = error_name },
+ },
+ id = BB_SERVER.current_request_id,
+ }
+ else
+ -- Success response
+ wrapped = {
+ jsonrpc = "2.0",
+ result = response,
+ id = BB_SERVER.current_request_id,
+ }
+ end
+
+ local success, json_str = pcall(json.encode, wrapped)
+ if not success then
+ sendDebugMessage("Failed to encode response: " .. tostring(json_str), "BB.SERVER")
+ return false
+ end
+
+ -- Send HTTP response
+ local http_response = format_http_response(200, "OK", json_str)
+ local sent = send_raw(http_response)
+
+ -- Close connection after response (Connection: close)
+ close_client()
+
+ return sent
+end
+
+--- Main update loop - called each frame
+---@param dispatcher Dispatcher
+function BB_SERVER.update(dispatcher)
+ if not BB_SERVER.server_socket then
+ return
+ end
+
+ -- Try to accept new connections
+ BB_SERVER.accept()
+
+ -- Handle existing client
+ if BB_SERVER.client_socket and BB_SERVER.client_state then
+ -- Read available data into buffer (non-blocking)
+ BB_SERVER.client_socket:settimeout(0)
+ local chunk, err, partial = BB_SERVER.client_socket:receive(RECV_CHUNK_SIZE)
+ local data = chunk or partial
+
+ if data and #data > 0 then
+ BB_SERVER.client_state.buffer = BB_SERVER.client_state.buffer .. data
+
+ -- Try to parse complete HTTP request
+ local request = try_parse_http()
+ if request then
+ handle_http_request(request, dispatcher)
+ end
+ elseif err == "closed" then
+ close_client()
+ end
+ end
+end
+
+--- Close server and all connections
+function BB_SERVER.close()
+ close_client()
+
+ if BB_SERVER.server_socket then
+ BB_SERVER.server_socket:close()
+ BB_SERVER.server_socket = nil
+ sendDebugMessage("Server closed", "BB.SERVER")
+ end
+end
diff --git a/src/lua/core/validator.lua b/src/lua/core/validator.lua
new file mode 100644
index 0000000..40b28e9
--- /dev/null
+++ b/src/lua/core/validator.lua
@@ -0,0 +1,94 @@
+--[[
+ Schema Validator - Fail-fast validation for endpoint arguments.
+ Types: string, integer, boolean, array, table.
+ No defaults or range validation (endpoints handle these).
+]]
+
+local Validator = {}
+
+---@param value any
+---@return boolean
+local function is_integer(value)
+ return type(value) == "number" and math.floor(value) == value
+end
+
+---@param value any
+---@return boolean
+local function is_array(value)
+ if type(value) ~= "table" then
+ return false
+ end
+ local count = 0
+ for k, _ in pairs(value) do
+ count = count + 1
+ if type(k) ~= "number" or k ~= count then
+ return false
+ end
+ end
+ return true
+end
+
+---@param field_name string
+---@param value any
+---@param field_schema Endpoint.Schema
+---@return boolean success
+---@return string? error_message
+---@return string? error_code
+local function validate_field(field_name, value, field_schema)
+ local expected_type = field_schema.type
+ if expected_type == "integer" then
+ if not is_integer(value) then
+ return false, "Field '" .. field_name .. "' must be an integer", BB_ERROR_NAMES.BAD_REQUEST
+ end
+ elseif expected_type == "array" then
+ if not is_array(value) then
+ return false, "Field '" .. field_name .. "' must be an array", BB_ERROR_NAMES.BAD_REQUEST
+ end
+ elseif expected_type == "table" then
+ if type(value) ~= "table" or (next(value) ~= nil and is_array(value)) then
+ return false, "Field '" .. field_name .. "' must be a table", BB_ERROR_NAMES.BAD_REQUEST
+ end
+ else
+ if type(value) ~= expected_type then
+ return false, "Field '" .. field_name .. "' must be of type " .. expected_type, BB_ERROR_NAMES.BAD_REQUEST
+ end
+ end
+ if expected_type == "array" and field_schema.items then
+ for i, item in ipairs(value) do
+ local item_type = field_schema.items
+ local item_valid = item_type == "integer" and is_integer(item) or type(item) == item_type
+ if not item_valid then
+ return false,
+ "Field '" .. field_name .. "' array item at index " .. (i - 1) .. " must be of type " .. item_type,
+ BB_ERROR_NAMES.BAD_REQUEST
+ end
+ end
+ end
+ return true
+end
+
+---@param args table
+---@param schema table
+---@return boolean success
+---@return string? error_message
+---@return string? error_code
+function Validator.validate(args, schema)
+ if type(args) ~= "table" then
+ return false, "Arguments must be a table", BB_ERROR_NAMES.BAD_REQUEST
+ end
+ for field_name, field_schema in pairs(schema) do
+ local value = args[field_name]
+ if field_schema.required and value == nil then
+ return false, "Missing required field '" .. field_name .. "'", BB_ERROR_NAMES.BAD_REQUEST
+ end
+ if value ~= nil then
+ local success, err_msg, err_code = validate_field(field_name, value, field_schema)
+ if not success then
+ return false, err_msg, err_code
+ end
+ end
+ end
+ return true
+end
+
+return Validator
diff --git a/src/lua/endpoints/add.lua b/src/lua/endpoints/add.lua
new file mode 100644
index 0000000..3e87d3d
--- /dev/null
+++ b/src/lua/endpoints/add.lua
@@ -0,0 +1,442 @@
+-- src/lua/endpoints/add.lua
+
+-- ==========================================================================
+-- Add Endpoint Params
+-- ==========================================================================
+
+---@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 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)
+---@field rental boolean? If true, the card will be rental (jokers only)
+
+-- ==========================================================================
+-- Add Endpoint Utils
+-- ==========================================================================
+
+-- Suit conversion table for playing cards
+local SUIT_MAP = {
+ H = "Hearts",
+ D = "Diamonds",
+ C = "Clubs",
+ S = "Spades",
+}
+
+-- Rank conversion table for playing cards
+local RANK_MAP = {
+ ["2"] = "2",
+ ["3"] = "3",
+ ["4"] = "4",
+ ["5"] = "5",
+ ["6"] = "6",
+ ["7"] = "7",
+ ["8"] = "8",
+ ["9"] = "9",
+ T = "10",
+ J = "Jack",
+ Q = "Queen",
+ K = "King",
+ 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
+local function detect_card_type(key)
+ local prefix = key:sub(1, 2)
+
+ if prefix == "j_" then
+ return "joker"
+ elseif prefix == "c_" then
+ return "consumable"
+ elseif prefix == "v_" then
+ return "voucher"
+ else
+ -- Check if it's a playing card format (SUIT_RANK like H_A)
+ if key:match("^[HDCS]_[2-9TJQKA]$") then
+ return "playing_card"
+ else
+ return nil
+ end
+ end
+end
+
+---Parse playing card key into rank and suit
+---@param key string The playing card key (e.g., "H_A")
+---@return string|nil rank The rank (e.g., "Ace", "10")
+---@return string|nil suit The suit (e.g., "Hearts", "Spades")
+local function parse_playing_card_key(key)
+ local suit_char = key:sub(1, 1)
+ local rank_char = key:sub(3, 3)
+
+ local suit = SUIT_MAP[suit_char]
+ local rank = RANK_MAP[rank_char]
+
+ if not suit or not rank then
+ return nil, nil
+ end
+
+ return rank, suit
+end
+
+-- ==========================================================================
+-- Add Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "add",
+
+ description = "Add a new card to the game (joker, consumable, voucher, 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)",
+ },
+ seal = {
+ type = "string",
+ required = false,
+ description = "Seal type (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)",
+ },
+ enhancement = {
+ type = "string",
+ required = false,
+ description = "Enhancement type (BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, LUCKY) - only valid for playing cards",
+ },
+ eternal = {
+ type = "boolean",
+ required = false,
+ description = "If true, the card will be eternal (cannot be sold or destroyed) - only valid for jokers",
+ },
+ perishable = {
+ type = "integer",
+ required = false,
+ description = "Number of rounds before card perishes (must be positive integer >= 1) - only valid for jokers",
+ },
+ rental = {
+ type = "boolean",
+ required = false,
+ description = "If true, the card will be rental (costs $1 per round) - only valid for jokers",
+ },
+ },
+
+ requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP, G.STATES.ROUND_EVAL },
+
+ ---@param args Request.Endpoint.Add.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init 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)",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Special validation for playing cards - can only be added in SELECTING_HAND state
+ if card_type == "playing_card" and G.STATE ~= G.STATES.SELECTING_HAND then
+ send_response({
+ message = "Playing cards can only be added in SELECTING_HAND state",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ return
+ end
+
+ -- Special validation for vouchers - can only be added in SHOP state
+ if card_type == "voucher" and G.STATE ~= G.STATES.SHOP then
+ send_response({
+ message = "Vouchers can only be added in SHOP state",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ return
+ end
+
+ -- Validate seal parameter is only for playing cards
+ if args.seal and card_type ~= "playing_card" then
+ send_response({
+ message = "Seal can only be applied to playing cards",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate and convert seal value
+ local seal_value = nil
+ if args.seal then
+ seal_value = SEAL_MAP[args.seal]
+ if not seal_value then
+ send_response({
+ message = "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ -- Validate edition parameter is only for jokers, playing cards, or consumables
+ if args.edition and card_type == "voucher" then
+ send_response({
+ message = "Edition cannot be applied to vouchers",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Special validation: consumables can only have NEGATIVE edition
+ if args.edition and card_type == "consumable" and args.edition ~= "NEGATIVE" then
+ send_response({
+ message = "Consumables can only have NEGATIVE edition",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate and convert edition value
+ local edition_value = nil
+ if args.edition then
+ edition_value = EDITION_MAP[args.edition]
+ if not edition_value then
+ send_response({
+ message = "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ -- Validate enhancement parameter is only for playing cards
+ if args.enhancement and card_type ~= "playing_card" then
+ send_response({
+ message = "Enhancement can only be applied to playing cards",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate and convert enhancement value
+ local enhancement_value = nil
+ if args.enhancement then
+ enhancement_value = ENHANCEMENT_MAP[args.enhancement]
+ if not enhancement_value then
+ send_response({
+ message = "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ -- Validate eternal parameter is only for jokers
+ if args.eternal and card_type ~= "joker" then
+ send_response({
+ message = "Eternal can only be applied to jokers",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate perishable parameter is only for jokers
+ if args.perishable and card_type ~= "joker" then
+ send_response({
+ message = "Perishable can only be applied to jokers",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate perishable value is a positive integer
+ if args.perishable then
+ if type(args.perishable) ~= "number" or args.perishable ~= math.floor(args.perishable) or args.perishable < 1 then
+ send_response({
+ message = "Perishable must be a positive integer (>= 1)",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ -- Validate rental parameter is only for jokers
+ if args.rental and card_type ~= "joker" then
+ send_response({
+ message = "Rental can only be applied to jokers",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Build SMODS.add_card parameters based on card type
+ local params
+
+ if card_type == "playing_card" then
+ -- Parse the playing card key
+ local rank, suit = parse_playing_card_key(args.key)
+ params = {
+ rank = rank,
+ suit = suit,
+ skip_materialize = true,
+ }
+
+ -- Add seal if provided
+ if seal_value then
+ params.seal = seal_value
+ end
+
+ -- Add edition if provided
+ if edition_value then
+ params.edition = edition_value
+ end
+
+ -- Add enhancement if provided
+ 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 = {
+ key = args.key,
+ skip_materialize = true,
+ stickers = {},
+ force_stickers = true,
+ }
+
+ -- Add edition if provided
+ if edition_value then
+ params.edition = edition_value
+ end
+
+ -- Add eternal if provided (jokers only - validation already done)
+ if args.eternal then
+ params.stickers[#params.stickers + 1] = "eternal"
+ end
+
+ -- Add perishable if provided (jokers only - validation already done)
+ if args.perishable then
+ params.stickers[#params.stickers + 1] = "perishable"
+ end
+
+ -- Add rental if provided (jokers only - validation already done)
+ if args.rental then
+ params.stickers[#params.stickers + 1] = "rental"
+ end
+ end
+
+ -- 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
+ local initial_voucher_count = G.shop_vouchers and G.shop_vouchers.config and G.shop_vouchers.config.card_count or 0
+ local initial_hand_count = G.hand and G.hand.config and G.hand.config.card_count or 0
+
+ sendDebugMessage("Initial voucher count: " .. initial_voucher_count, "BB.ENDPOINTS")
+
+ -- Call SMODS.add_card with error handling
+ local success, result = pcall(SMODS.add_card, params)
+
+ if not success then
+ send_response({
+ message = "Failed to add card: " .. args.key,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Set custom perish_tally if perishable was provided
+ if args.perishable and result and result.ability then
+ 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",
+ blocking = false,
+ func = function()
+ -- Verify card was added based on card type
+ local added = false
+
+ if card_type == "joker" then
+ added = G.jokers and G.jokers.config and G.jokers.config.card_count == initial_joker_count + 1
+ elseif card_type == "consumable" then
+ added = G.consumeables
+ and G.consumeables.config
+ and G.consumeables.config.card_count == initial_consumable_count + 1
+ elseif card_type == "voucher" then
+ added = G.shop_vouchers
+ and G.shop_vouchers.config
+ and G.shop_vouchers.config.card_count == initial_voucher_count + 1
+ elseif card_type == "playing_card" then
+ added = G.hand and G.hand.config and G.hand.config.card_count == initial_hand_count + 1
+ end
+
+ -- Check state stability
+ local state_stable = G.STATE_COMPLETE == true and not G.CONTROLLER.locked
+
+ -- Check valid state (still in one of the allowed states)
+ local valid_state = (
+ G.STATE == G.STATES.SHOP
+ or G.STATE == G.STATES.SELECTING_HAND
+ or G.STATE == G.STATES.ROUND_EVAL
+ )
+
+ -- All conditions must be met
+ if added and state_stable and valid_state then
+ sendDebugMessage("Card added successfully: " .. args.key, "BB.ENDPOINTS")
+ send_response(BB_GAMESTATE.get_gamestate())
+ return true
+ end
+
+ return false
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/buy.lua b/src/lua/endpoints/buy.lua
new file mode 100644
index 0000000..bf1208d
--- /dev/null
+++ b/src/lua/endpoints/buy.lua
@@ -0,0 +1,249 @@
+-- src/lua/endpoints/buy.lua
+
+-- ==========================================================================
+-- Buy Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Buy.Params
+---@field card integer? 0-based index of card to buy
+---@field voucher integer? 0-based index of voucher to buy
+---@field pack integer? 0-based index of pack to buy
+
+-- ==========================================================================
+-- Buy Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "buy",
+
+ description = "Buy a card from the shop",
+
+ schema = {
+ card = {
+ type = "integer",
+ required = false,
+ description = "0-based index of card to buy",
+ },
+ voucher = {
+ type = "integer",
+ required = false,
+ description = "0-based index of voucher to buy",
+ },
+ pack = {
+ type = "integer",
+ required = false,
+ description = "0-based index of pack to buy",
+ },
+ },
+
+ requires_state = { G.STATES.SHOP },
+
+ ---@param args Request.Endpoint.Buy.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init buy()", "BB.ENDPOINTS")
+ local gamestate = BB_GAMESTATE.get_gamestate()
+ sendDebugMessage("Gamestate is : " .. gamestate.state, "BB.ENDPOINTS")
+ sendDebugMessage(
+ "Gamestate native is : " .. (G.consumeables and G.consumeables.config and G.consumeables.config.card_count or 0),
+ "BB.ENDPOINTS"
+ )
+ local area
+ local pos
+ local set = 0
+ if args.card then
+ area = gamestate.shop
+ pos = args.card + 1
+ set = set + 1
+ end
+ if args.voucher then
+ area = gamestate.vouchers
+ pos = args.voucher + 1
+ set = set + 1
+ end
+ if args.pack then
+ area = gamestate.packs
+ pos = args.pack + 1
+ set = set + 1
+ end
+
+ -- Validate that only one of card, voucher, or pack is provided
+ if not area then
+ send_response({
+ message = "Invalid arguments. You must provide one of: card, voucher, pack",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate that only one of card, voucher, or pack is provided
+ if set > 1 then
+ send_response({
+ message = "Invalid arguments. Cannot provide more than one of: card, voucher, or pack",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate that the area has cards
+ if #area.cards == 0 then
+ local msg
+ if args.card then
+ msg = "No jokers/consumables/cards in the shop. Reroll to restock the shop"
+ elseif args.voucher then
+ msg = "No vouchers to redeem. Defeat boss blind to restock"
+ elseif args.pack then
+ msg = "No boosters/standard/buffoon packs to open"
+ end
+ send_response({
+ message = msg,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate card index is in range
+ if not area.cards[pos] then
+ send_response({
+ message = "Card index out of range. Index: " .. args.card .. ", Available cards: " .. area.count,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Get the card
+ local card = area.cards[pos]
+
+ -- Check if the card can be afforded (accounting for Credit Card joker via bankrupt_at)
+ local available_money = G.GAME.dollars - G.GAME.bankrupt_at
+ if card.cost.buy > 0 and card.cost.buy > available_money then
+ send_response({
+ message = "Card is not affordable. Cost: " .. card.cost.buy .. ", Available money: " .. available_money,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Ensure there is space in joker area
+ if card.set == "JOKER" then
+ if gamestate.jokers.count >= gamestate.jokers.limit then
+ send_response({
+ message = "Cannot purchase joker card, joker slots are full. Current: "
+ .. gamestate.jokers.count
+ .. ", Limit: "
+ .. gamestate.jokers.limit,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ -- Ensure there is space in consumable area
+ if card.set == "PLANET" or card.set == "SPECTRAL" or card.set == "TAROT" then
+ if gamestate.consumables.count >= gamestate.consumables.limit then
+ send_response({
+ message = "Cannot purchase consumable card, consumable slots are full. Current: "
+ .. gamestate.consumables.count
+ .. ", Limit: "
+ .. gamestate.consumables.limit,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ local initial_shop_count = 0
+ local initial_dest_count = 0
+ local initial_money = gamestate.money
+
+ if args.card then
+ initial_shop_count = gamestate.shop.count
+ initial_dest_count = gamestate.jokers.count
+ + gamestate.consumables.count
+ + (G.deck and G.deck.config and G.deck.config.card_count or 0)
+ elseif args.voucher then
+ initial_shop_count = gamestate.vouchers.count
+ initial_dest_count = 0
+ for _ in pairs(gamestate.used_vouchers) do
+ initial_dest_count = initial_dest_count + 1
+ end
+ end
+
+ -- Get the buy button from the card
+ local btn
+ if args.card then
+ btn = G.shop_jokers.cards[pos].children.buy_button.definition
+ elseif args.voucher then
+ btn = G.shop_vouchers.cards[pos].children.buy_button.definition
+ elseif args.pack then
+ btn = G.shop_booster.cards[pos].children.buy_button.definition
+ end
+ if not btn then
+ send_response({
+ message = "No buy button found for card",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ -- Use appropriate function: use_card for vouchers, buy_from_shop for others
+ if args.voucher or args.pack then
+ G.FUNCS.use_card(btn)
+ else
+ G.FUNCS.buy_from_shop(btn)
+ end
+
+ -- Wait for buy completion with comprehensive verification
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local done = false
+
+ if args.card then
+ local shop_count = (G.shop_jokers and G.shop_jokers.config and G.shop_jokers.config.card_count or 0)
+ local dest_count = (G.jokers and G.jokers.config and G.jokers.config.card_count or 0)
+ + (G.consumeables and G.consumeables.config and G.consumeables.config.card_count or 0)
+ + (G.deck and G.deck.config and G.deck.config.card_count or 0)
+ local shop_decreased = (shop_count == initial_shop_count - 1)
+ local dest_increased = (dest_count == initial_dest_count + 1)
+ local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy)
+ if shop_decreased and dest_increased and money_deducted and G.STATE == G.STATES.SHOP then
+ done = true
+ end
+ elseif args.voucher then
+ local shop_count = (G.shop_vouchers and G.shop_vouchers.config and G.shop_vouchers.config.card_count or 0)
+ local dest_count = 0
+ if G.GAME.used_vouchers then
+ for _ in pairs(G.GAME.used_vouchers) do
+ dest_count = dest_count + 1
+ end
+ end
+ local shop_decreased = (shop_count == initial_shop_count - 1)
+ local dest_increased = (dest_count == initial_dest_count + 1)
+ local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy)
+
+ if shop_decreased and dest_increased and money_deducted and G.STATE == G.STATES.SHOP then
+ done = true
+ end
+ elseif args.pack then
+ local money_deducted = (G.GAME.dollars == initial_money - card.cost.buy)
+ local pack_cards_count = (G.pack_cards and G.pack_cards.config and G.pack_cards.config.card_count or 0)
+ if money_deducted and pack_cards_count > 0 and G.STATE == G.STATES.SMODS_BOOSTER_OPENED then
+ done = true
+ end
+ end
+
+ if done then
+ sendDebugMessage("Buy completed successfully", "BB.ENDPOINTS")
+ send_response(BB_GAMESTATE.get_gamestate())
+ return true
+ end
+
+ return false
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/cash_out.lua b/src/lua/endpoints/cash_out.lua
new file mode 100644
index 0000000..03e7ee6
--- /dev/null
+++ b/src/lua/endpoints/cash_out.lua
@@ -0,0 +1,60 @@
+-- src/lua/endpoints/cash_out.lua
+
+-- ==========================================================================
+-- CashOut Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.CashOut.Params
+
+-- ==========================================================================
+-- CashOut Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "cash_out",
+
+ description = "Cash out and collect round rewards",
+
+ schema = {},
+
+ requires_state = { G.STATES.ROUND_EVAL },
+
+ ---@param _ Request.Endpoint.CashOut.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ sendDebugMessage("Init cash_out()", "BB.ENDPOINTS")
+ G.FUNCS.cash_out({ config = {} })
+
+ local num_items = function(area)
+ local count = 0
+ if area and area.cards then
+ for _, v in ipairs(area.cards) do
+ if v.children.buy_button and v.children.buy_button.definition then
+ count = count + 1
+ end
+ end
+ end
+ return count
+ end
+
+ -- Wait for SHOP state after state transition completes
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local done = false
+ 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")
+ send_response(BB_GAMESTATE.get_gamestate())
+ return done
+ end
+ end
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/discard.lua b/src/lua/endpoints/discard.lua
new file mode 100644
index 0000000..da0b5c0
--- /dev/null
+++ b/src/lua/endpoints/discard.lua
@@ -0,0 +1,109 @@
+-- src/lua/endpoints/discard.lua
+
+-- ==========================================================================
+-- Discard Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Discard.Params
+---@field cards integer[] 0-based indices of cards to discard
+
+-- ==========================================================================
+-- Discard Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "discard",
+
+ description = "Discard cards from the hand",
+
+ schema = {
+ cards = {
+ type = "array",
+ required = true,
+ items = "integer",
+ description = "0-based indices of cards to discard",
+ },
+ },
+
+ requires_state = { G.STATES.SELECTING_HAND },
+
+ ---@param args Request.Endpoint.Discard.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init discard()", "BB.ENDPOINTS")
+ if #args.cards == 0 then
+ send_response({
+ message = "Must provide at least one card to discard",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ if G.GAME.current_round.discards_left <= 0 then
+ send_response({
+ message = "No discards left",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ if #args.cards > G.hand.config.highlighted_limit then
+ send_response({
+ message = "You can only discard " .. G.hand.config.highlighted_limit .. " cards",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ for _, card_index in ipairs(args.cards) do
+ if not G.hand.cards[card_index + 1] then
+ send_response({
+ message = "Invalid card index: " .. card_index,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ 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()
+
+ for _, card_index in ipairs(args.cards) do
+ G.hand.cards[card_index + 1]:click()
+ end
+
+ ---@diagnostic disable-next-line: undefined-field
+ local discard_button = UIBox:get_UIE_by_ID("discard_button", G.buttons.UIRoot)
+ assert(discard_button ~= nil, "discard() discard button not found")
+ G.FUNCS.discard_cards_from_highlighted(discard_button)
+
+ local draw_to_hand = false
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "immediate",
+ blocking = false,
+ blockable = false,
+ created_on_pause = true,
+ func = function()
+ -- State progression for discard:
+ -- Discard always continues current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND
+ if G.STATE == G.STATES.DRAW_TO_HAND then
+ draw_to_hand = true
+ end
+
+ if draw_to_hand and G.buttons and G.STATE == G.STATES.SELECTING_HAND then
+ sendDebugMessage("Return discard()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ return true
+ end
+
+ return false
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/gamestate.lua b/src/lua/endpoints/gamestate.lua
new file mode 100644
index 0000000..7d88100
--- /dev/null
+++ b/src/lua/endpoints/gamestate.lua
@@ -0,0 +1,32 @@
+-- src/lua/endpoints/gamestate.lua
+
+-- ==========================================================================
+-- Gamestate Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Gamestate.Params
+
+-- ==========================================================================
+-- Gamestate Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "gamestate",
+
+ description = "Get current game state",
+
+ schema = {},
+
+ requires_state = nil,
+
+ ---@param _ Request.Endpoint.Gamestate.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ sendDebugMessage("Init gamestate()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ sendDebugMessage("Return gamestate()", "BB.ENDPOINTS")
+ send_response(state_data)
+ end,
+}
diff --git a/src/lua/endpoints/health.lua b/src/lua/endpoints/health.lua
new file mode 100644
index 0000000..f43b412
--- /dev/null
+++ b/src/lua/endpoints/health.lua
@@ -0,0 +1,33 @@
+-- src/lua/endpoints/health.lua
+
+-- ==========================================================================
+-- Health Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Health.Params
+
+-- ==========================================================================
+-- Health Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "health",
+
+ description = "Health check endpoint for connection testing",
+
+ schema = {},
+
+ requires_state = nil,
+
+ ---@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")
+ send_response({
+ status = "ok",
+ })
+ end,
+}
diff --git a/src/lua/endpoints/load.lua b/src/lua/endpoints/load.lua
new file mode 100644
index 0000000..2381259
--- /dev/null
+++ b/src/lua/endpoints/load.lua
@@ -0,0 +1,166 @@
+-- src/lua/endpoints/load.lua
+
+-- ==========================================================================
+-- Load Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Load.Params
+---@field path string File path to the save file
+
+-- ==========================================================================
+-- Load Endpoint Utils
+-- ==========================================================================
+
+local nativefs = require("nativefs")
+
+-- ==========================================================================
+-- Load Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "load",
+
+ description = "Load a saved run state from a file",
+
+ schema = {
+ path = {
+ type = "string",
+ required = true,
+ description = "File path to the save file",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Load.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ local path = args.path
+
+ -- Check if file exists
+ local file_info = nativefs.getInfo(path)
+ if not file_info or file_info.type ~= "file" then
+ send_response({
+ message = "File not found: '" .. path .. "'",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ -- Read file using nativefs
+ local compressed_data = nativefs.read(path)
+ ---@cast compressed_data string
+ if not compressed_data then
+ send_response({
+ message = "Failed to read save file",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ -- Write to temp location for get_compressed to read
+ local temp_filename = "balatrobot_temp_load.jkr"
+ local save_dir = love.filesystem.getSaveDirectory()
+ local temp_path = save_dir .. "/" .. temp_filename
+
+ local write_success = nativefs.write(temp_path, compressed_data)
+ if not write_success then
+ send_response({
+ message = "Failed to prepare save file for loading",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ -- Load using game's built-in functions
+ G:delete_run()
+ G.SAVED_GAME = get_compressed(temp_filename) ---@diagnostic disable-line: undefined-global
+
+ if G.SAVED_GAME == nil then
+ send_response({
+ message = "Invalid save file format",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ love.filesystem.remove(temp_filename)
+ return
+ end
+
+ G.SAVED_GAME = STR_UNPACK(G.SAVED_GAME)
+
+ -- Temporarily suppress "Card area not instantiated" warnings during load
+ -- These are expected when loading a save from shop state (shop CardAreas
+ -- are created later when the shop UI renders, and the game handles this)
+ local original_print = print
+ print = function(msg)
+ if type(msg) == "string" and msg:find("ERROR LOADING GAME: Card area") then
+ return -- suppress expected warning
+ end
+ original_print(msg)
+ end
+
+ G:start_run({ savetext = G.SAVED_GAME })
+
+ -- Restore original print
+ print = original_print
+
+ -- Clean up
+ love.filesystem.remove(temp_filename)
+
+ local num_items = function(area)
+ local count = 0
+ if area and area.cards then
+ for _, v in ipairs(area.cards) do
+ if v.children.buy_button and v.children.buy_button.definition then
+ count = count + 1
+ end
+ end
+ end
+ return count
+ end
+
+ G.E_MANAGER:add_event(Event({
+ no_delete = true,
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local done = false
+
+ if not G.STATE_COMPLETE or G.CONTROLLER.locked then
+ return false
+ end
+
+ if G.STATE == G.STATES.BLIND_SELECT then
+ done = G.GAME.blind_on_deck ~= nil
+ and G.blind_select_opts ~= nil
+ and G.blind_select_opts["small"]:get_UIE_by_ID("tag_Small") ~= nil
+ end
+
+ if G.STATE == G.STATES.SELECTING_HAND then
+ done = G.hand ~= nil
+ end
+
+ if G.STATE == G.STATES.ROUND_EVAL and G.round_eval then
+ for _, b in ipairs(G.I.UIBOX) do
+ if b:get_UIE_by_ID("cash_out_button") then
+ done = true
+ end
+ end
+ end
+
+ if G.STATE == G.STATES.SHOP then
+ done = num_items(G.shop_booster) > 0 or num_items(G.shop_jokers) > 0 or num_items(G.shop_vouchers) > 0
+ end
+
+ if done then
+ send_response({
+ success = true,
+ path = path,
+ })
+ end
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/menu.lua b/src/lua/endpoints/menu.lua
new file mode 100644
index 0000000..daade39
--- /dev/null
+++ b/src/lua/endpoints/menu.lua
@@ -0,0 +1,50 @@
+-- src/lua/endpoints/menu.lua
+
+-- ==========================================================================
+-- Menu Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Menu.Params
+
+-- ==========================================================================
+-- Menu Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "menu",
+
+ description = "Return to the main menu from any game state",
+
+ schema = {},
+
+ requires_state = nil,
+
+ ---@param _ Request.Endpoint.Menu.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ sendDebugMessage("Init menu()", "BB.ENDPOINTS")
+ if G.STATE ~= G.STATES.MENU then
+ G.FUNCS.go_to_menu({})
+ end
+
+ -- Wait for menu state using Balatro's Event Manager
+ G.E_MANAGER:add_event(Event({
+ no_delete = true,
+ trigger = "condition",
+ blocking = true,
+ func = function()
+ local done = G.STATE == G.STATES.MENU and G.MAIN_MENU_UI
+
+ if done then
+ sendDebugMessage("Return menu()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ end
+
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/next_round.lua b/src/lua/endpoints/next_round.lua
new file mode 100644
index 0000000..0210dc1
--- /dev/null
+++ b/src/lua/endpoints/next_round.lua
@@ -0,0 +1,46 @@
+-- src/lua/endpoints/next_round.lua
+
+-- ==========================================================================
+-- NextRound Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.NextRound.Params
+
+-- ==========================================================================
+-- NextRound Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "next_round",
+
+ description = "Leave the shop and advance to blind selection",
+
+ schema = {},
+
+ requires_state = { G.STATES.SHOP },
+
+ ---@param _ Request.Endpoint.NextRound.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ sendDebugMessage("Init next_round()", "BB.ENDPOINTS")
+ G.FUNCS.toggle_shop({})
+
+ -- Wait for BLIND_SELECT state after leaving shop
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local blind_pane = G.blind_select_opts[string.lower(G.GAME.blind_on_deck)]
+ local select_button = blind_pane:get_UIE_by_ID("select_blind_button")
+ local done = G.STATE == G.STATES.BLIND_SELECT and select_button ~= nil
+ if done then
+ sendDebugMessage("Return next_round() - reached BLIND_SELECT state", "BB.ENDPOINTS")
+ send_response(BB_GAMESTATE.get_gamestate())
+ end
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/play.lua b/src/lua/endpoints/play.lua
new file mode 100644
index 0000000..493fd27
--- /dev/null
+++ b/src/lua/endpoints/play.lua
@@ -0,0 +1,158 @@
+-- src/lua/endpoints/play.lua
+
+-- ==========================================================================
+-- Play Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Play.Params
+---@field cards integer[] 0-based indices of cards to play
+
+-- ==========================================================================
+-- Play Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "play",
+
+ description = "Play a card from the hand",
+
+ schema = {
+ cards = {
+ type = "array",
+ required = true,
+ items = "integer",
+ description = "0-based indices of cards to play",
+ },
+ },
+
+ requires_state = { G.STATES.SELECTING_HAND },
+
+ ---@param args Request.Endpoint.Play.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init play()", "BB.ENDPOINTS")
+ if #args.cards == 0 then
+ send_response({
+ message = "Must provide at least one card to play",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ if #args.cards > G.hand.config.highlighted_limit then
+ send_response({
+ message = "You can only play " .. G.hand.config.highlighted_limit .. " cards",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ for _, card_index in ipairs(args.cards) do
+ if not G.hand.cards[card_index + 1] then
+ send_response({
+ message = "Invalid card index: " .. card_index,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ 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()
+
+ for _, card_index in ipairs(args.cards) do
+ G.hand.cards[card_index + 1]:click()
+ end
+
+ ---@diagnostic disable-next-line: undefined-field
+ local play_button = UIBox:get_UIE_by_ID("play_button", G.buttons.UIRoot)
+ assert(play_button ~= nil, "play() play button not found")
+ G.FUNCS.play_cards_from_highlighted(play_button)
+
+ local hand_played = false
+ local draw_to_hand = false
+
+ -- NOTE: GAME_OVER detection cannot happen inside this event function
+ -- because when G.STATE becomes GAME_OVER, the game sets G.SETTINGS.paused = true,
+ -- which stops all event processing. This callback is set so that love.update
+ -- (which runs even when paused) can detect GAME_OVER immediately.
+ BB_GAMESTATE.on_game_over = send_response
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ blockable = false,
+ created_on_pause = true,
+ func = function()
+ -- State progression:
+ -- Loss: HAND_PLAYED -> NEW_ROUND -> (game paused) -> GAME_OVER
+ -- Win round: HAND_PLAYED -> NEW_ROUND -> ROUND_EVAL
+ -- Win game: HAND_PLAYED -> NEW_ROUND -> ROUND_EVAL (with G.GAME.won = true)
+ -- Keep playing current round: HAND_PLAYED -> DRAW_TO_HAND -> SELECTING_HAND
+
+ -- Track state transitions
+ if G.STATE == G.STATES.HAND_PLAYED then
+ hand_played = true
+ end
+
+ if G.STATE == G.STATES.DRAW_TO_HAND then
+ draw_to_hand = true
+ end
+
+ -- if G.STATE == G.STATES.GAME_OVER then
+ -- -- NOTE: GAME_OVER is detected by gamestate.on_game_over callback in love.update
+ -- return true
+ -- end
+
+ if G.STATE == G.STATES.ROUND_EVAL then
+ -- Early exit if basic conditions not met
+ if not G.round_eval or not G.STATE_COMPLETE or G.CONTROLLER.locked then
+ 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
+ end
+
+ -- Wait for first scoring row (blind1) to be added to the UI
+ -- This ensures the main scoring events have started processing
+ local has_blind1 = G.round_eval:get_UIE_by_ID("dollar_blind1") ~= nil
+
+ -- Wait for cash_out_button to ensure the last scoring row (bottom) has been processed
+ local has_cash_out_button = false
+ for _, b in ipairs(G.I.UIBOX) do
+ if b:get_UIE_by_ID("cash_out_button") then
+ has_cash_out_button = true
+ break
+ end
+ end
+
+ -- Both first and last scoring rows must be present
+ if has_blind1 and has_cash_out_button then
+ 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")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ return true
+ end
+
+ return false
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/rearrange.lua b/src/lua/endpoints/rearrange.lua
new file mode 100644
index 0000000..c195861
--- /dev/null
+++ b/src/lua/endpoints/rearrange.lua
@@ -0,0 +1,212 @@
+-- src/lua/endpoints/rearrange.lua
+
+-- ==========================================================================
+-- Rearrange Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Rearrange.Params
+---@field hand integer[]? 0-based indices representing new order of cards in hand
+---@field jokers integer[]? 0-based indices representing new order of jokers
+---@field consumables integer[]? 0-based indices representing new order of consumables
+
+-- ==========================================================================
+-- Rearrange Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "rearrange",
+
+ description = "Rearrange cards in hand, jokers, or consumables",
+
+ schema = {
+ hand = {
+ type = "array",
+ required = false,
+ items = "integer",
+ description = "0-based indices representing new order of cards in hand",
+ },
+ jokers = {
+ type = "array",
+ required = false,
+ items = "integer",
+ description = "0-based indices representing new order of jokers",
+ },
+ consumables = {
+ type = "array",
+ required = false,
+ items = "integer",
+ description = "0-based indices representing new order of consumables",
+ },
+ },
+
+ requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP },
+
+ ---@param args Request.Endpoint.Rearrange.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ -- 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
+ send_response({
+ message = "Must provide exactly one of: hand, jokers, or consumables",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ elseif param_count > 1 then
+ send_response({
+ message = "Can only rearrange one type at a time",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Determine which type to rearrange and validate state-specific requirements
+ local rearrange_type, source_array, indices, type_name
+
+ if args.hand then
+ -- Cards can only be rearranged during SELECTING_HAND
+ if G.STATE ~= G.STATES.SELECTING_HAND then
+ send_response({
+ message = "Can only rearrange hand during hand selection",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ return
+ end
+
+ -- Validate G.hand exists (not tested)
+ if not G.hand or not G.hand.cards then
+ send_response({
+ message = "No hand available to rearrange",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ rearrange_type = "hand"
+ source_array = G.hand.cards
+ indices = args.hand
+ type_name = "hand"
+ elseif args.jokers then
+ -- Validate G.jokers exists (not tested)
+ if not G.jokers or not G.jokers.cards then
+ send_response({
+ message = "No jokers available to rearrange",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ rearrange_type = "jokers"
+ source_array = G.jokers.cards
+ indices = args.jokers
+ type_name = "jokers"
+ else -- args.consumables
+ -- Validate G.consumeables exists (not tested)
+ if not G.consumeables or not G.consumeables.cards then
+ send_response({
+ message = "No consumables available to rearrange",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ rearrange_type = "consumables"
+ source_array = G.consumeables.cards
+ indices = args.consumables
+ type_name = "consumables"
+ end
+
+ assert(type(indices) == "table", "indices must be a table")
+
+ -- Validate permutation: correct length, no duplicates, all indices present
+ -- Check length matches
+ if #indices ~= #source_array then
+ send_response({
+ message = "Must provide exactly " .. #source_array .. " indices for " .. type_name,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Check for duplicates and range
+ local seen = {}
+ for _, idx in ipairs(indices) do
+ -- Check range [0, N-1]
+ if idx < 0 or idx >= #source_array then
+ send_response({
+ message = "Index out of range for " .. type_name .. ": " .. idx,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Check for duplicates
+ if seen[idx] then
+ send_response({
+ message = "Duplicate index in " .. type_name .. ": " .. idx,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ seen[idx] = true
+ end
+
+ -- Create new array from indices (convert 0-based to 1-based)
+ local new_array = {}
+ for _, old_index in ipairs(indices) do
+ table.insert(new_array, source_array[old_index + 1])
+ end
+
+ -- Replace the array in game state
+ if rearrange_type == "hand" then
+ G.hand.cards = new_array
+ elseif rearrange_type == "jokers" then
+ G.jokers.cards = new_array
+ else -- consumables
+ G.consumeables.cards = new_array
+ end
+
+ -- Update order fields on each card
+ for i, card in ipairs(new_array) do
+ if rearrange_type == "hand" then
+ card.config.card.order = i
+ if card.config.center then
+ card.config.center.order = i
+ end
+ else -- jokers or consumables
+ if card.ability then
+ card.ability.order = i
+ end
+ if card.config and card.config.center then
+ card.config.center.order = i
+ end
+ end
+ end
+
+ -- Wait for completion: state should remain stable after rearranging
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ -- Check that we're still in a valid state and arrays exist
+ local done = false
+ if args.hand then
+ done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil
+ elseif args.jokers then
+ done = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) and G.jokers ~= nil
+ else -- consumables
+ done = (G.STATE == G.STATES.SHOP or G.STATE == G.STATES.SELECTING_HAND) and G.consumeables ~= nil
+ end
+
+ if done then
+ sendDebugMessage("Return rearrange()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ end
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/reroll.lua b/src/lua/endpoints/reroll.lua
new file mode 100644
index 0000000..3f05d09
--- /dev/null
+++ b/src/lua/endpoints/reroll.lua
@@ -0,0 +1,56 @@
+-- src/lua/endpoints/reroll.lua
+
+-- ==========================================================================
+-- Reroll Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Reroll.Params
+
+-- ==========================================================================
+-- Reroll Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "reroll",
+
+ description = "Reroll to update the cards in the shop area",
+
+ schema = {},
+
+ requires_state = { G.STATES.SHOP },
+
+ ---@param _ Request.Endpoint.Reroll.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ -- 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
+
+ if reroll_cost > 0 and available_money < reroll_cost then
+ send_response({
+ message = "Not enough dollars to reroll. Available: " .. available_money .. ", Required: " .. reroll_cost,
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ sendDebugMessage("Init reroll()", "BB.ENDPOINTS")
+ G.FUNCS.reroll_shop(nil)
+
+ -- Wait for shop state to confirm reroll completed
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local done = G.STATE == G.STATES.SHOP
+ if done then
+ sendDebugMessage("Return reroll() - shop rerolled", "BB.ENDPOINTS")
+ send_response(BB_GAMESTATE.get_gamestate())
+ end
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/save.lua b/src/lua/endpoints/save.lua
new file mode 100644
index 0000000..dd0b30e
--- /dev/null
+++ b/src/lua/endpoints/save.lua
@@ -0,0 +1,103 @@
+-- src/lua/endpoints/save.lua
+
+-- ==========================================================================
+-- Save Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Save.Params
+---@field path string File path for the save file
+
+-- ==========================================================================
+-- Save Endpoint Utils
+-- ==========================================================================
+
+local nativefs = require("nativefs")
+
+-- ==========================================================================
+-- Save Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "save",
+
+ description = "Save the current run state to a file",
+
+ schema = {
+ path = {
+ type = "string",
+ required = true,
+ description = "File path for the save file",
+ },
+ },
+
+ requires_state = {
+ G.STATES.SELECTING_HAND,
+ G.STATES.HAND_PLAYED,
+ G.STATES.DRAW_TO_HAND,
+ G.STATES.GAME_OVER,
+ G.STATES.SHOP,
+ G.STATES.PLAY_TAROT,
+ G.STATES.BLIND_SELECT,
+ G.STATES.ROUND_EVAL,
+ G.STATES.TAROT_PACK,
+ G.STATES.PLANET_PACK,
+ G.STATES.SPECTRAL_PACK,
+ G.STATES.STANDARD_PACK,
+ G.STATES.BUFFOON_PACK,
+ G.STATES.NEW_ROUND,
+ },
+
+ ---@param args Request.Endpoint.Save.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ local path = args.path
+
+ -- Validate we're in a run
+ if not G.STAGE or G.STAGE ~= G.STAGES.RUN then
+ send_response({
+ message = "Can only save during an active run",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ return
+ end
+
+ -- Call save_run() and use compress_and_save
+ save_run() ---@diagnostic disable-line: undefined-global
+
+ local temp_filename = "balatrobot_temp_save.jkr"
+ compress_and_save(temp_filename, G.ARGS.save_run) ---@diagnostic disable-line: undefined-global
+
+ -- Read from temp and write to target path using nativefs
+ local save_dir = love.filesystem.getSaveDirectory()
+ local temp_path = save_dir .. "/" .. temp_filename
+ local compressed_data = nativefs.read(temp_path)
+ ---@cast compressed_data string
+
+ if not compressed_data then
+ send_response({
+ message = "Failed to save game state",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ local write_success = nativefs.write(path, compressed_data)
+ if not write_success then
+ send_response({
+ message = "Failed to write save file to '" .. path .. "'",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ -- Clean up
+ love.filesystem.remove(temp_filename)
+
+ send_response({
+ success = true,
+ path = path,
+ })
+ end,
+}
diff --git a/src/lua/endpoints/screenshot.lua b/src/lua/endpoints/screenshot.lua
new file mode 100644
index 0000000..daada16
--- /dev/null
+++ b/src/lua/endpoints/screenshot.lua
@@ -0,0 +1,73 @@
+-- src/lua/endpoints/screenshot.lua
+
+-- ==========================================================================
+-- Screenshot Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Screenshot.Params
+---@field path string File path for the screenshot file
+
+-- ==========================================================================
+-- Screenshot Endpoint Utils
+-- ==========================================================================
+
+local nativefs = require("nativefs")
+
+-- ==========================================================================
+-- Screenshot Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "screenshot",
+
+ description = "Take a screenshot of the current game state",
+
+ schema = {
+ path = {
+ type = "string",
+ required = true,
+ description = "File path for the screenshot file",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Screenshot.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ local path = args.path
+
+ love.graphics.captureScreenshot(function(imagedata)
+ -- Encode ImageData to PNG format
+ local filedata = imagedata:encode("png")
+
+ if not filedata then
+ send_response({
+ message = "Failed to encode screenshot",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ -- Get PNG data as string
+ local png_data = filedata:getString()
+
+ -- Write to target path using nativefs
+ local write_success = nativefs.write(path, png_data)
+ if not write_success then
+ send_response({
+ message = "Failed to write screenshot file to '" .. path .. "'",
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ send_response({
+ success = true,
+ path = path,
+ })
+ end)
+ end,
+}
diff --git a/src/lua/endpoints/select.lua b/src/lua/endpoints/select.lua
new file mode 100644
index 0000000..d59aac7
--- /dev/null
+++ b/src/lua/endpoints/select.lua
@@ -0,0 +1,54 @@
+-- src/lua/endpoints/select.lua
+
+-- ==========================================================================
+-- Select Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Select.Params
+
+-- ==========================================================================
+-- Select Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "select",
+
+ description = "Select the current blind",
+
+ schema = {},
+
+ requires_state = { G.STATES.BLIND_SELECT },
+
+ ---@param _ Request.Endpoint.Select.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ sendDebugMessage("Init 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")
+ local blind_pane = G.blind_select_opts[string.lower(current_blind)]
+ assert(blind_pane ~= nil, "select() blind pane not found: " .. current_blind)
+ local select_button = blind_pane:get_UIE_by_ID("select_blind_button")
+ assert(select_button ~= nil, "select() select button not found: " .. current_blind)
+
+ -- Execute blind selection
+ G.FUNCS.select_blind(select_button)
+
+ -- Wait for completion: transition to SELECTING_HAND with facing_blind flag set
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local done = G.STATE == G.STATES.SELECTING_HAND and G.hand ~= nil
+ if done then
+ sendDebugMessage("Return select()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ end
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/sell.lua b/src/lua/endpoints/sell.lua
new file mode 100644
index 0000000..4ef8e60
--- /dev/null
+++ b/src/lua/endpoints/sell.lua
@@ -0,0 +1,156 @@
+-- src/lua/endpoints/sell.lua
+
+-- ==========================================================================
+-- Sell Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Sell.Params
+---@field joker integer? 0-based index of joker to sell
+---@field consumable integer? 0-based index of consumable to sell
+
+-- ==========================================================================
+-- Sell Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "sell",
+
+ description = "Sell a joker or consumable from player inventory",
+
+ schema = {
+ joker = {
+ type = "integer",
+ required = false,
+ description = "0-based index of joker to sell",
+ },
+ consumable = {
+ type = "integer",
+ required = false,
+ description = "0-based index of consumable to sell",
+ },
+ },
+
+ requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP },
+
+ ---@param args Request.Endpoint.Sell.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init sell()", "BB.ENDPOINTS")
+
+ -- Validate exactly one parameter is provided
+ local param_count = (args.joker and 1 or 0) + (args.consumable and 1 or 0)
+ if param_count == 0 then
+ send_response({
+ message = "Must provide exactly one of: joker or consumable",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ elseif param_count > 1 then
+ send_response({
+ message = "Can only sell one item at a time",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Determine which type to sell and validate existence
+ local source_array, pos, sell_type
+
+ if args.joker then
+ -- Validate G.jokers exists and has cards
+ if not G.jokers or not G.jokers.config or G.jokers.config.card_count == 0 then
+ send_response({
+ message = "No jokers available to sell",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+ source_array = G.jokers.cards
+ pos = args.joker + 1 -- Convert to 1-based
+ sell_type = "joker"
+ else -- args.consumable
+ -- Validate G.consumeables exists and has cards
+ if not G.consumeables or not G.consumeables.config or G.consumeables.config.card_count == 0 then
+ send_response({
+ message = "No consumables available to sell",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+ source_array = G.consumeables.cards
+ pos = args.consumable + 1 -- Convert to 1-based
+ sell_type = "consumable"
+ end
+
+ -- Validate card exists at index
+ if not source_array[pos] then
+ send_response({
+ message = "Index out of range for " .. sell_type .. ": " .. (pos - 1),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ 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
+
+ -- Create mock UI element for G.FUNCS.sell_card
+ local mock_element = {
+ config = {
+ ref_table = card,
+ },
+ }
+
+ -- Call the game function to trigger sell
+ G.FUNCS.sell_card(mock_element)
+
+ -- Wait for sell completion with comprehensive 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)
+
+ -- 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
+ 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)
+
+ -- 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")
+ send_response(BB_GAMESTATE.get_gamestate())
+ return true
+ end
+
+ return false
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/set.lua b/src/lua/endpoints/set.lua
new file mode 100644
index 0000000..a597669
--- /dev/null
+++ b/src/lua/endpoints/set.lua
@@ -0,0 +1,221 @@
+-- src/lua/endpoints/set.lua
+
+-- ==========================================================================
+-- Set Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Set.Params
+---@field money integer? New money amount
+---@field chips integer? New chips amount
+---@field ante integer? New ante number
+---@field round integer? New round number
+---@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
+
+-- ==========================================================================
+-- Set Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "set",
+
+ description = "Set a in-game value",
+
+ schema = {
+ money = {
+ type = "integer",
+ required = false,
+ description = "New money amount",
+ },
+ chips = {
+ type = "integer",
+ required = false,
+ description = "New chips amount",
+ },
+ ante = {
+ type = "integer",
+ required = false,
+ description = "New ante number",
+ },
+ round = {
+ type = "integer",
+ required = false,
+ description = "New round number",
+ },
+ hands = {
+ type = "integer",
+ required = false,
+ description = "New number of hands left number",
+ },
+ discards = {
+ type = "integer",
+ required = false,
+ description = "New number of discards left number",
+ },
+ shop = {
+ type = "boolean",
+ required = false,
+ description = "Re-stock shop with new items",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Set.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init set()", "BB.ENDPOINTS")
+
+ -- Validate we're in a run
+ if G.STAGE and G.STAGE ~= G.STAGES.RUN then
+ send_response({
+ message = "Can only set during an active run",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ return
+ end
+
+ -- Check for at least one field
+ if
+ args.money == nil
+ and args.ante == nil
+ and args.chips == nil
+ and args.round == nil
+ and args.hands == nil
+ and args.discards == nil
+ and args.shop == nil
+ then
+ send_response({
+ message = "Must provide at least one field to set",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Set money
+ if args.money then
+ if args.money < 0 then
+ send_response({
+ message = "Money must be a positive integer",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.dollars = args.money
+ sendDebugMessage("Set money to " .. G.GAME.dollars, "BB.ENDPOINTS")
+ end
+
+ -- Set chips
+ if args.chips then
+ if args.chips < 0 then
+ send_response({
+ message = "Chips must be a positive integer",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.chips = args.chips
+ sendDebugMessage("Set chips to " .. G.GAME.chips, "BB.ENDPOINTS")
+ end
+
+ -- Set ante
+ if args.ante then
+ if args.ante < 0 then
+ send_response({
+ message = "Ante must be a positive integer",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.round_resets.ante = args.ante
+ sendDebugMessage("Set ante to " .. G.GAME.round_resets.ante, "BB.ENDPOINTS")
+ end
+
+ -- Set round
+ if args.round then
+ if args.round < 0 then
+ send_response({
+ message = "Round must be a positive integer",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.round = args.round
+ sendDebugMessage("Set round to " .. G.GAME.round, "BB.ENDPOINTS")
+ end
+
+ -- Set hands
+ if args.hands then
+ if args.hands < 0 then
+ send_response({
+ message = "Hands must be a positive integer",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.current_round.hands_left = args.hands
+ sendDebugMessage("Set hands to " .. G.GAME.current_round.hands_left, "BB.ENDPOINTS")
+ end
+
+ -- Set discards
+ if args.discards then
+ if args.discards < 0 then
+ send_response({
+ message = "Discards must be a positive integer",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ G.GAME.current_round.discards_left = args.discards
+ sendDebugMessage("Set discards to " .. G.GAME.current_round.discards_left, "BB.ENDPOINTS")
+ end
+
+ if args.shop then
+ if G.STATE ~= G.STATES.SHOP then
+ send_response({
+ message = "Can re-stock shop only in SHOP state",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+ if G.shop then
+ G.shop:remove()
+ G.shop = nil
+ end
+ if G.SHOP_SIGN then
+ G.SHOP_SIGN:remove()
+ G.SHOP_SIGN = nil
+ end
+ G.GAME.current_round.used_packs = nil
+ G.STATE_COMPLETE = false
+ G:update_shop()
+ end
+
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ if args.shop then
+ local done_vouchers = G.shop_vouchers and G.shop_vouchers.config and G.shop_vouchers.config.card_count > 0
+ 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")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ return true
+ end
+ return false
+ else
+ sendDebugMessage("Return set()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ return true
+ end
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/skip.lua b/src/lua/endpoints/skip.lua
new file mode 100644
index 0000000..0e684be
--- /dev/null
+++ b/src/lua/endpoints/skip.lua
@@ -0,0 +1,79 @@
+-- src/lua/endpoints/skip.lua
+
+-- ==========================================================================
+-- Skip Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Skip.Params
+
+-- ==========================================================================
+-- Skip Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "skip",
+
+ description = "Skip the current blind (Small or Big only, not Boss)",
+
+ schema = {},
+
+ requires_state = { G.STATES.BLIND_SELECT },
+
+ ---@param _ Request.Endpoint.Skip.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ sendDebugMessage("Init skip()", "BB.ENDPOINTS")
+
+ -- Get the current blind on deck (similar to select endpoint)
+ local current_blind = G.GAME.blind_on_deck
+ assert(current_blind ~= nil, "skip() called with no blind on deck")
+ local current_blind_key = string.lower(current_blind)
+ local blind = BB_GAMESTATE.get_blinds_info()[current_blind_key]
+ assert(blind ~= nil, "skip() blind not found: " .. current_blind)
+
+ if blind.type == "BOSS" then
+ sendDebugMessage("skip() cannot skip Boss blind: " .. current_blind, "BB.ENDPOINTS")
+ send_response({
+ message = "Cannot skip Boss blind",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ -- Get the skip button from the tag element
+ local blind_pane = G.blind_select_opts[current_blind_key]
+ assert(blind_pane ~= nil, "skip() blind pane not found: " .. current_blind)
+ local tag_element = blind_pane:get_UIE_by_ID("tag_" .. current_blind)
+ assert(tag_element ~= nil, "skip() tag element not found: " .. current_blind)
+ local skip_button = tag_element.children[2]
+ assert(skip_button ~= nil, "skip() skip button not found: " .. current_blind)
+
+ -- Execute blind skip
+ G.FUNCS.skip_blind(skip_button)
+
+ -- Wait for the skip to complete
+ -- Completion is indicated by the blind state changing to "Skipped"
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = true,
+ func = function()
+ local blinds = BB_GAMESTATE.get_blinds_info()
+ local done = (
+ G.STATE == G.STATES.BLIND_SELECT
+ and G.GAME.blind_on_deck ~= nil
+ and G.blind_select_opts ~= nil
+ and blinds[current_blind_key].status == "SKIPPED"
+ )
+ if done then
+ sendDebugMessage("Return skip()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ end
+
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/start.lua b/src/lua/endpoints/start.lua
new file mode 100644
index 0000000..5bcefa6
--- /dev/null
+++ b/src/lua/endpoints/start.lua
@@ -0,0 +1,170 @@
+-- src/lua/endpoints/start.lua
+
+-- ==========================================================================
+-- Start Endpoint Params
+-- ==========================================================================
+
+---@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 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
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "start",
+
+ description = "Start a new game run with specified deck and stake",
+
+ schema = {
+ deck = {
+ type = "string",
+ required = true,
+ description = "Deck enum value (e.g., 'RED', 'BLUE', 'YELLOW')",
+ },
+ stake = {
+ type = "string",
+ required = true,
+ description = "Stake enum value (e.g., 'WHITE', 'RED', 'GREEN', 'BLACK', 'BLUE', 'PURPLE', 'ORANGE', 'GOLD')",
+ },
+ seed = {
+ type = "string",
+ required = false,
+ description = "Optional seed for the run",
+ },
+ },
+
+ requires_state = { G.STATES.MENU },
+
+ ---@param args Request.Endpoint.Start.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init 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")
+ send_response({
+ message = "Invalid stake enum. Must be one of: WHITE, RED, GREEN, BLACK, BLUE, PURPLE, ORANGE, GOLD. Got: "
+ .. tostring(args.stake),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- 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")
+ 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),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Reset the game (setup_run and exit_overlay_menu)
+ G.FUNCS.setup_run({ config = {} })
+ G.FUNCS.exit_overlay_menu()
+
+ -- Find and set the deck using the mapped deck name
+ 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")
+ G.GAME.selected_back:change_to(deck_data)
+ G.GAME.viewed_back:change_to(deck_data)
+ deck_found = true
+ break
+ end
+ end
+ end
+
+ if not deck_found then
+ sendDebugMessage("start() deck not found in game data: " .. deck_name, "BB.ENDPOINTS")
+ send_response({
+ message = "Deck not found in game data: " .. deck_name,
+ name = BB_ERROR_NAMES.INTERNAL_ERROR,
+ })
+ return
+ end
+
+ -- Start the run with stake number and optional seed
+ local run_params = { stake = stake_number }
+ if args.seed then
+ run_params.seed = args.seed
+ end
+
+ sendDebugMessage(
+ "Starting run with stake="
+ .. tostring(stake_number)
+ .. " ("
+ .. args.stake
+ .. "), seed="
+ .. tostring(args.seed or "none"),
+ "BB.ENDPOINTS"
+ )
+ G.FUNCS.start_run(nil, run_params)
+
+ -- Wait for run to start using Balatro's Event Manager
+ G.E_MANAGER:add_event(Event({
+ no_delete = true,
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ local done = (
+ G.GAME.blind_on_deck ~= nil
+ and G.blind_select_opts ~= nil
+ and G.blind_select_opts["small"]:get_UIE_by_ID("tag_Small") ~= nil
+ )
+ if done then
+ sendDebugMessage("Return start()", "BB.ENDPOINTS")
+ local state_data = BB_GAMESTATE.get_gamestate()
+ send_response(state_data)
+ end
+
+ return done
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/endpoints/tests/echo.lua b/src/lua/endpoints/tests/echo.lua
new file mode 100644
index 0000000..af04cb3
--- /dev/null
+++ b/src/lua/endpoints/tests/echo.lua
@@ -0,0 +1,68 @@
+-- src/lua/endpoints/tests/echo.lua
+
+-- ==========================================================================
+-- Test Echo Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Test.Echo.Params
+---@field required_string string A required string field
+---@field optional_string? string Optional string field
+---@field required_integer integer Required integer field
+---@field optional_integer? integer Optional integer field
+---@field optional_array_integers? integer[] Optional array of integers
+
+-- ==========================================================================
+-- Test Echo Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "test_endpoint",
+
+ description = "Test endpoint with schema for dispatcher testing",
+
+ schema = {
+ required_string = {
+ type = "string",
+ required = true,
+ description = "A required string field",
+ },
+
+ optional_string = {
+ type = "string",
+ required = false,
+ description = "Optional string field",
+ },
+
+ required_integer = {
+ type = "integer",
+ required = true,
+ description = "Required integer field",
+ },
+
+ optional_integer = {
+ type = "integer",
+ required = false,
+ description = "Optional integer field",
+ },
+
+ optional_array_integers = {
+ type = "array",
+ required = false,
+ items = "integer",
+ description = "Optional array of integers",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Test.Echo.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ send_response({
+ success = true,
+ received_args = args,
+ })
+ end,
+}
diff --git a/src/lua/endpoints/tests/endpoint.lua b/src/lua/endpoints/tests/endpoint.lua
new file mode 100644
index 0000000..e44f360
--- /dev/null
+++ b/src/lua/endpoints/tests/endpoint.lua
@@ -0,0 +1,68 @@
+-- src/lua/endpoints/tests/endpoint.lua
+
+-- ==========================================================================
+-- Test Endpoint Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Test.Endpoint.Params
+---@field required_string string A required string field
+---@field optional_string? string Optional string field
+---@field required_integer integer Required integer field
+---@field optional_integer? integer Optional integer field
+---@field optional_array_integers? integer[] Optional array of integers
+
+-- ==========================================================================
+-- Test Endpoint Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "test_endpoint",
+
+ description = "Test endpoint with schema for dispatcher testing",
+
+ schema = {
+ required_string = {
+ type = "string",
+ required = true,
+ description = "A required string field",
+ },
+
+ optional_string = {
+ type = "string",
+ required = false,
+ description = "Optional string field",
+ },
+
+ required_integer = {
+ type = "integer",
+ required = true,
+ description = "Required integer field",
+ },
+
+ optional_integer = {
+ type = "integer",
+ required = false,
+ description = "Optional integer field",
+ },
+
+ optional_array_integers = {
+ type = "array",
+ required = false,
+ items = "integer",
+ description = "Optional array of integers",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Test.Endpoint.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ send_response({
+ success = true,
+ received_args = args,
+ })
+ end,
+}
diff --git a/src/lua/endpoints/tests/error.lua b/src/lua/endpoints/tests/error.lua
new file mode 100644
index 0000000..e3eb91e
--- /dev/null
+++ b/src/lua/endpoints/tests/error.lua
@@ -0,0 +1,43 @@
+-- src/lua/endpoints/tests/error.lua
+
+-- ==========================================================================
+-- Test Error Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Test.Error.Params
+---@field error_type "throw_error"|"success" Whether to throw an error or succeed
+
+-- ==========================================================================
+-- Test Error Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "test_error_endpoint",
+
+ description = "Test endpoint that throws runtime errors",
+
+ schema = {
+ error_type = {
+ type = "string",
+ required = true,
+ enum = { "throw_error", "success" },
+ description = "Whether to throw an error or succeed",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Test.Error.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ if args.error_type == "throw_error" then
+ error("Intentional test error from endpoint execution")
+ else
+ send_response({
+ success = true,
+ })
+ end
+ end,
+}
diff --git a/src/lua/endpoints/tests/state.lua b/src/lua/endpoints/tests/state.lua
new file mode 100644
index 0000000..5413661
--- /dev/null
+++ b/src/lua/endpoints/tests/state.lua
@@ -0,0 +1,32 @@
+-- src/lua/endpoints/tests/state.lua
+
+-- ==========================================================================
+-- Test State Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Test.State.Params
+
+-- ==========================================================================
+-- TestState Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "test_state_endpoint",
+
+ description = "Test endpoint that requires specific game states",
+
+ schema = {},
+
+ requires_state = { G.STATES.SPLASH, G.STATES.MENU },
+
+ ---@param _ Request.Endpoint.Test.State.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(_, send_response)
+ send_response({
+ success = true,
+ state_validated = true,
+ })
+ end,
+}
diff --git a/src/lua/endpoints/tests/validation.lua b/src/lua/endpoints/tests/validation.lua
new file mode 100644
index 0000000..9eace0e
--- /dev/null
+++ b/src/lua/endpoints/tests/validation.lua
@@ -0,0 +1,82 @@
+-- src/lua/endpoints/tests/validation.lua
+
+-- ==========================================================================
+-- Test Validation Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Test.Validation.Params
+---@field required_field string Required string field for basic validation testing
+---@field string_field? string Optional string field for type validation
+---@field integer_field? integer Optional integer field for type validation
+---@field boolean_field? boolean Optional boolean field for type validation
+---@field array_field? table Optional array field for type validation
+---@field table_field? table Optional table field for type validation
+---@field array_of_integers? integer[] Optional array that must contain only integers
+
+-- ==========================================================================
+-- Test Validation Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "test_validation",
+
+ description = "Comprehensive validation test endpoint for validator module testing",
+
+ schema = {
+ required_field = {
+ type = "string",
+ required = true,
+ description = "Required string field for basic validation testing",
+ },
+
+ string_field = {
+ type = "string",
+ required = false,
+ description = "Optional string field for type validation",
+ },
+
+ integer_field = {
+ type = "integer",
+ required = false,
+ description = "Optional integer field for type validation",
+ },
+
+ boolean_field = {
+ type = "boolean",
+ required = false,
+ description = "Optional boolean field for type validation",
+ },
+
+ array_field = {
+ type = "array",
+ required = false,
+ description = "Optional array field for type validation",
+ },
+
+ table_field = {
+ type = "table",
+ required = false,
+ description = "Optional table field for type validation",
+ },
+
+ array_of_integers = {
+ type = "array",
+ required = false,
+ items = "integer",
+ description = "Optional array that must contain only integers",
+ },
+ },
+
+ requires_state = nil,
+
+ ---@param args Request.Endpoint.Test.Validation.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ send_response({
+ success = true,
+ received_args = args,
+ })
+ end,
+}
diff --git a/src/lua/endpoints/use.lua b/src/lua/endpoints/use.lua
new file mode 100644
index 0000000..5e842c9
--- /dev/null
+++ b/src/lua/endpoints/use.lua
@@ -0,0 +1,215 @@
+-- src/lua/endpoints/use.lua
+
+-- ==========================================================================
+-- Use Endpoint Params
+-- ==========================================================================
+
+---@class Request.Endpoint.Use.Params
+---@field consumable integer 0-based index of consumable to use
+---@field cards integer[]? 0-based indices of cards to target
+
+-- ==========================================================================
+-- Use Endpoint
+-- ==========================================================================
+
+---@type Endpoint
+return {
+
+ name = "use",
+
+ description = "Use a consumable card with optional target cards",
+
+ schema = {
+ consumable = {
+ type = "integer",
+ required = true,
+ description = "0-based index of consumable to use",
+ },
+ cards = {
+ type = "array",
+ required = false,
+ description = "0-based indices of cards to target (required only if consumable requires cards)",
+ items = "integer",
+ },
+ },
+
+ requires_state = { G.STATES.SELECTING_HAND, G.STATES.SHOP },
+
+ ---@param args Request.Endpoint.Use.Params
+ ---@param send_response fun(response: Response.Endpoint)
+ execute = function(args, send_response)
+ sendDebugMessage("Init use()", "BB.ENDPOINTS")
+
+ -- Step 1: Consumable Index Validation
+ if args.consumable < 0 or args.consumable >= #G.consumeables.cards then
+ send_response({
+ message = "Consumable index out of range: " .. args.consumable,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ local consumable_card = G.consumeables.cards[args.consumable + 1]
+
+ -- Step 2: Determine Card Selection Requirements
+ local requires_cards = consumable_card.ability.consumeable.max_highlighted ~= nil
+
+ -- Step 3: State Validation for Card-Selecting Consumables
+ if requires_cards and G.STATE ~= G.STATES.SELECTING_HAND then
+ send_response({
+ message = "Consumable '"
+ .. consumable_card.ability.name
+ .. "' requires card selection and can only be used in SELECTING_HAND state",
+ name = BB_ERROR_NAMES.INVALID_STATE,
+ })
+ return
+ end
+
+ -- Step 4: Cards Parameter Validation
+ if requires_cards then
+ if not args.cards or #args.cards == 0 then
+ send_response({
+ message = "Consumable '" .. consumable_card.ability.name .. "' requires card selection",
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- Validate each card index is in range
+ for _, card_idx in ipairs(args.cards) do
+ if card_idx < 0 or card_idx >= #G.hand.cards then
+ send_response({
+ message = "Card index out of range: " .. card_idx,
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+ end
+
+ -- Step 5: Explicit Min/Max Card Count Validation
+ if requires_cards then
+ local min_cards = consumable_card.ability.consumeable.min_highlighted or 1
+ local max_cards = consumable_card.ability.consumeable.max_highlighted
+ local card_count = #args.cards
+
+ -- Check if consumable requires exact number of cards
+ 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_card.ability.name,
+ min_cards,
+ min_cards == 1 and "" or "s",
+ card_count
+ ),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ -- For consumables with range, check min and max separately
+ if card_count < min_cards then
+ send_response({
+ message = string.format(
+ "Consumable '%s' requires at least %d card%s (provided: %d)",
+ consumable_card.ability.name,
+ min_cards,
+ min_cards == 1 and "" or "s",
+ card_count
+ ),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+
+ if card_count > max_cards then
+ send_response({
+ message = string.format(
+ "Consumable '%s' requires at most %d card%s (provided: %d)",
+ consumable_card.ability.name,
+ max_cards,
+ max_cards == 1 and "" or "s",
+ card_count
+ ),
+ name = BB_ERROR_NAMES.BAD_REQUEST,
+ })
+ return
+ end
+ end
+
+ -- Step 6: Card Selection Setup
+ if requires_cards then
+ -- Clear existing selection
+ for i = #G.hand.highlighted, 1, -1 do
+ G.hand:remove_from_highlighted(G.hand.highlighted[i], true)
+ end
+
+ -- Add cards using proper method
+ for _, card_idx in ipairs(args.cards) do
+ local hand_card = G.hand.cards[card_idx + 1] -- Convert 0-based to 1-based
+ G.hand:add_to_highlighted(hand_card, true) -- silent=true
+ end
+
+ sendDebugMessage(
+ string.format("Selected %d cards for '%s'", #args.cards, consumable_card.ability.name),
+ "BB.ENDPOINTS"
+ )
+ end
+
+ -- Step 7: Game-Level Validation (e.g. try to use Familiar Spectral when G.hand is not available)
+ if not consumable_card:can_use_consumeable() then
+ send_response({
+ message = "Consumable '" .. consumable_card.ability.name .. "' cannot be used at this time",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ -- Step 8: Space Check (not tested)
+ if consumable_card:check_use() then
+ send_response({
+ message = "Cannot use consumable '" .. consumable_card.ability.name .. "': insufficient space",
+ name = BB_ERROR_NAMES.NOT_ALLOWED,
+ })
+ return
+ end
+
+ -- Execution
+ sendDebugMessage("Executing use() for consumable: " .. consumable_card.ability.name, "BB.ENDPOINTS")
+
+ -- Create mock UI element for game function
+ local mock_element = {
+ config = {
+ ref_table = consumable_card,
+ },
+ }
+
+ -- Call game's use_card function
+ G.FUNCS.use_card(mock_element, true, true)
+
+ -- Completion Detection
+ G.E_MANAGER:add_event(Event({
+ trigger = "condition",
+ blocking = false,
+ func = function()
+ -- Condition 1: State restored
+ local state_restored = G.STATE == G.STATES.SELECTING_HAND or G.STATE == G.STATES.SHOP
+
+ -- Condition 2: Controller unlocked
+ local controller_unlocked = not G.CONTROLLER.locks.use
+
+ -- Condition 3: no stop use
+ 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")
+ send_response(BB_GAMESTATE.get_gamestate())
+ return true
+ end
+
+ return false
+ end,
+ }))
+ end,
+}
diff --git a/src/lua/log.lua b/src/lua/log.lua
deleted file mode 100644
index 4e57212..0000000
--- a/src/lua/log.lua
+++ /dev/null
@@ -1,526 +0,0 @@
-local json = require("json")
-local socket = require("socket")
-
-LOG = {
- mod_path = nil,
- current_run_file = nil,
- pending_logs = {},
- game_state_before = {},
-}
-
--- =============================================================================
--- Utility Functions
--- =============================================================================
-
----Writes a log entry to the JSONL file
----@param log_entry LogEntry The log entry to write
-function LOG.write(log_entry)
- if LOG.current_run_file then
- local log_line = json.encode(log_entry) .. "\n"
- local file = io.open(LOG.current_run_file, "a")
- if file then
- file:write(log_line)
- file:close()
- else
- sendErrorMessage("Failed to open log file for writing: " .. LOG.current_run_file, "LOG")
- end
- end
-end
-
----Processes pending logs by checking completion conditions
-function LOG.update()
- for key, pending_log in pairs(LOG.pending_logs) do
- if pending_log.condition() then
- -- Update the log entry with after function call info
- pending_log.log_entry["timestamp_ms_after"] = math.floor(socket.gettime() * 1000)
- pending_log.log_entry["game_state_after"] = utils.get_game_state()
- LOG.write(pending_log.log_entry)
- -- Prepare for the next log entry
- LOG.game_state_before = pending_log.log_entry.game_state_after
- LOG.pending_logs[key] = nil
- end
- end
-end
-
---- Schedules a log entry to be written when the condition is met
----@param function_call FunctionCall The function call to log
-function LOG.schedule_write(function_call)
- sendInfoMessage(function_call.name .. "(" .. json.encode(function_call.arguments) .. ")", "LOG")
-
- local log_entry = {
- ["function"] = function_call,
- -- before function call
- timestamp_ms_before = math.floor(socket.gettime() * 1000),
- game_state_before = LOG.game_state_before,
- -- after function call (will be filled in by LOG.write)
- timestamp_ms_after = nil,
- game_state_after = nil,
- }
-
- local pending_key = function_call.name .. "_" .. tostring(socket.gettime())
- LOG.pending_logs[pending_key] = {
- log_entry = log_entry,
- condition = utils.COMPLETION_CONDITIONS[function_call.name][function_call.arguments.action or ""],
- }
-end
-
--- =============================================================================
--- Hooks
--- =============================================================================
-
--- -----------------------------------------------------------------------------
--- go_to_menu Hook
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.go_to_menu
-function hook_go_to_menu()
- local original_function = G.FUNCS.go_to_menu
- G.FUNCS.go_to_menu = function(...)
- local function_call = {
- name = "go_to_menu",
- arguments = {},
- }
- LOG.schedule_write(function_call)
- return original_function(...)
- end
- sendDebugMessage("Hooked into G.FUNCS.go_to_menu for logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- start_run Hook
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.start_run
-function hook_start_run()
- local original_function = G.FUNCS.start_run
- G.FUNCS.start_run = function(game_state, args)
- -- Generate new log file for this run
- if args.log_path then
- local file = io.open(args.log_path, "r")
- if file then
- file:close()
- sendErrorMessage("Log file already exists, refusing to overwrite: " .. args.log_path, "LOG")
- return
- end
- LOG.current_run_file = args.log_path
- sendInfoMessage("Starting new run log: " .. args.log_path, "LOG")
- else
- local timestamp = tostring(os.date("!%Y%m%dT%H%M%S"))
- LOG.current_run_file = LOG.mod_path .. "runs/" .. timestamp .. ".jsonl"
- sendInfoMessage("Starting new run log: " .. timestamp .. ".jsonl", "LOG")
- end
- local function_call = {
- name = "start_run",
- arguments = {
- deck = G.GAME.selected_back.name,
- stake = args.stake,
- seed = args.seed,
- challenge = args.challenge and args.challenge.name,
- },
- }
- LOG.schedule_write(function_call)
- return original_function(game_state, args)
- end
- sendDebugMessage("Hooked into G.FUNCS.start_run for logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- skip_or_select_blind Hooks
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.select_blind
-function hook_select_blind()
- local original_function = G.FUNCS.select_blind
- G.FUNCS.select_blind = function(args)
- local function_call = { name = "skip_or_select_blind", arguments = { action = "select" } }
- LOG.schedule_write(function_call)
- return original_function(args)
- end
- sendDebugMessage("Hooked into G.FUNCS.select_blind for logging", "LOG")
-end
-
----Hooks into G.FUNCS.skip_blind
-function hook_skip_blind()
- local original_function = G.FUNCS.skip_blind
- G.FUNCS.skip_blind = function(args)
- local function_call = { name = "skip_or_select_blind", arguments = { action = "skip" } }
- LOG.schedule_write(function_call)
- return original_function(args)
- end
- sendDebugMessage("Hooked into G.FUNCS.skip_blind for logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- play_hand_or_discard Hooks
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.play_cards_from_highlighted
-function hook_play_cards_from_highlighted()
- local original_function = G.FUNCS.play_cards_from_highlighted
- G.FUNCS.play_cards_from_highlighted = function(...)
- local cards = {}
- for i, card in ipairs(G.hand.cards) do
- if card.highlighted then
- table.insert(cards, i - 1) -- Adjust for 0-based indexing
- end
- end
- local function_call = { name = "play_hand_or_discard", arguments = { action = "play_hand", cards = cards } }
- LOG.schedule_write(function_call)
- return original_function(...)
- end
- sendDebugMessage("Hooked into G.FUNCS.play_cards_from_highlighted for logging", "LOG")
-end
-
----Hooks into G.FUNCS.discard_cards_from_highlighted
-function hook_discard_cards_from_highlighted()
- local original_function = G.FUNCS.discard_cards_from_highlighted
- G.FUNCS.discard_cards_from_highlighted = function(...)
- local cards = {}
- for i, card in ipairs(G.hand.cards) do
- if card.highlighted then
- table.insert(cards, i - 1) -- Adjust for 0-based indexing
- end
- end
- local function_call = { name = "play_hand_or_discard", arguments = { action = "discard", cards = cards } }
- LOG.schedule_write(function_call)
- return original_function(...)
- end
- sendDebugMessage("Hooked into G.FUNCS.discard_cards_from_highlighted for logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- cash_out Hook
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.cash_out
-function hook_cash_out()
- local original_function = G.FUNCS.cash_out
- G.FUNCS.cash_out = function(...)
- local function_call = { name = "cash_out", arguments = {} }
- LOG.schedule_write(function_call)
- return original_function(...)
- end
- sendDebugMessage("Hooked into G.FUNCS.cash_out for logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- shop Hooks
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.toggle_shop
-function hook_toggle_shop()
- local original_function = G.FUNCS.toggle_shop
- G.FUNCS.toggle_shop = function(...)
- local function_call = { name = "shop", arguments = { action = "next_round" } }
- LOG.schedule_write(function_call)
- return original_function(...)
- end
- sendDebugMessage("Hooked into G.FUNCS.toggle_shop for logging", "LOG")
-end
-
--- Hooks into G.FUNCS.buy_from_shop for buy_card and buy_and_use_card
-function hook_buy_card()
- local original_function = G.FUNCS.buy_from_shop
- -- e is the UI element for buy_card button on the targeted card.
-
- G.FUNCS.buy_from_shop = function(e)
- local card_id = e.config.ref_table.sort_id
- -- If e.config.id is present, it is the buy_and_use_card button.
- local action = (e.config and e.config.id) or "buy_card"
- -- Normalize internal button id to API action name
- if action == "buy_and_use" then
- action = "buy_and_use_card"
- end
- for i, card in ipairs(G.shop_jokers.cards) do
- if card.sort_id == card_id then
- local function_call = { name = "shop", arguments = { action = action, index = i - 1 } }
- LOG.schedule_write(function_call)
- break
- end
- end
- return original_function(e)
- end
- sendDebugMessage("Hooked into G.FUNCS.buy_from_shop for logging", "LOG")
-end
-
----Hooks into G.FUNCS.use_card for voucher redemption and consumable usage logging
-function hook_use_card()
- local original_function = G.FUNCS.use_card
- -- e is the UI element for use_card button on the targeted card.
- G.FUNCS.use_card = function(e)
- local card = e.config.ref_table
-
- if card.ability.set == "Voucher" then
- for i, shop_card in ipairs(G.shop_vouchers.cards) do
- if shop_card.sort_id == card.sort_id then
- local function_call = { name = "shop", arguments = { action = "redeem_voucher", index = i - 1 } }
- LOG.schedule_write(function_call)
- break
- end
- end
- elseif
- (card.ability.set == "Planet" or card.ability.set == "Tarot" or card.ability.set == "Spectral")
- and card.area == G.consumeables
- then
- -- Only log consumables used from consumables area
- for i, consumable_card in ipairs(G.consumeables.cards) do
- if consumable_card.sort_id == card.sort_id then
- local function_call = { name = "use_consumable", arguments = { index = i - 1 } }
- LOG.schedule_write(function_call)
- break
- end
- end
- end
-
- return original_function(e)
- end
- sendDebugMessage("Hooked into G.FUNCS.use_card for voucher and consumable logging", "LOG")
-end
-
----Hooks into G.FUNCS.reroll_shop
-function hook_reroll_shop()
- local original_function = G.FUNCS.reroll_shop
- G.FUNCS.reroll_shop = function(...)
- local function_call = { name = "shop", arguments = { action = "reroll" } }
- LOG.schedule_write(function_call)
- return original_function(...)
- end
- sendDebugMessage("Hooked into G.FUNCS.reroll_shop for logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- hand_rearrange Hook (also handles joker and consumenables rearrange)
--- -----------------------------------------------------------------------------
-
----Hooks into CardArea:align_cards for hand and joker reordering detection
-function hook_hand_rearrange()
- local original_function = CardArea.align_cards
- local previous_orders = {
- hand = {},
- joker = {},
- consumables = {},
- }
- -- local previous_hand_order = {}
- -- local previous_joker_order = {}
- CardArea.align_cards = function(self, ...)
- -- Monitor hand, joker, and consumable card areas
- if
- ---@diagnostic disable-next-line: undefined-field
- self.config
- ---@diagnostic disable-next-line: undefined-field
- and (self.config.type == "hand" or self.config.type == "joker")
- -- consumables are type "joker"
- ---@diagnostic disable-next-line: undefined-field
- and self.cards
- ---@diagnostic disable-next-line: undefined-field
- and #self.cards > 0
- then
- -- Call the original function with all arguments
- local result = original_function(self, ...)
-
- ---@diagnostic disable-next-line: undefined-field
- if self.config.card_count ~= #self.cards then
- -- We're adding/removing cards
- return result
- end
-
- local current_order = {}
- -- Capture current card order after alignment
- ---@diagnostic disable-next-line: undefined-field
- for i, card in ipairs(self.cards) do
- current_order[i] = card.sort_id
- end
-
- ---@diagnostic disable-next-line: undefined-field
- previous_order = previous_orders[self.config.type]
-
- if utils.sets_equal(previous_order, current_order) then
- local order_changed = false
- for i = 1, #current_order do
- if previous_order[i] ~= current_order[i] then
- order_changed = true
- break
- end
- end
-
- if order_changed then
- -- Compute rearrangement to interpret the action
- -- Map every card-id → its position in the old list
- local lookup = {}
- for pos, card_id in ipairs(previous_order) do
- lookup[card_id] = pos - 1 -- zero-based for the API
- end
-
- -- Walk the new order and translate
- local cards = {}
- for pos, card_id in ipairs(current_order) do
- cards[pos] = lookup[card_id]
- end
-
- local function_call
-
- if self.config.type == "hand" then ---@diagnostic disable-line: undefined-field
- function_call = {
- name = "rearrange_hand",
- arguments = { cards = cards },
- }
- elseif self.config.type == "joker" then ---@diagnostic disable-line: undefined-field
- -- Need to distinguish between actual jokers and consumables
- -- Check if any cards in this area are consumables
- local are_jokers = false
- local are_consumables = false
-
- ---@diagnostic disable-next-line: undefined-field
- for _, card in ipairs(self.cards) do
- if card.ability and card.ability.set == "Joker" then
- are_jokers = true
- elseif card.ability and card.ability.consumeable then
- are_consumables = true
- end
- end
-
- if are_consumables and not are_jokers then
- function_call = {
- name = "rearrange_consumables",
- arguments = { consumables = cards },
- }
- elseif are_jokers and not are_consumables then
- function_call = {
- name = "rearrange_jokers",
- arguments = { jokers = cards },
- }
- else
- function_call = {
- name = "unknown_rearrange",
- arguments = {},
- }
- sendErrorMessage("Unknown card type for rearrange: " .. tostring(self.config.type), "LOG") ---@diagnostic disable-line: undefined-field
- end
- end
-
- -- NOTE: We cannot schedule a log write at this point because we do not have
- -- access to the game state before the function call. The game state is only
- -- available after the function executes, so we need to recreate the "before"
- -- state manually by using the most recent known state (LOG.game_state_before).
-
- -- HACK: The timestamp for the log entry is problematic because this hook runs
- -- within the game loop, and we cannot accurately compute the "before" timestamp
- -- at the time of the function call. To address this, we use the same timestamp
- -- for both "before" and "after" states. This approach ensures that the log entry
- -- is consistent, but it may slightly reduce the accuracy of the timing information.
-
- local timestamp_ms = math.floor(socket.gettime() * 1000)
-
- local log_entry = {
- ["function"] = function_call,
- timestamp_ms_before = timestamp_ms,
- game_state_before = LOG.game_state_before,
- timestamp_ms_after = timestamp_ms,
- game_state_after = utils.get_game_state(),
- }
-
- sendInfoMessage(function_call.name .. "(" .. json.encode(function_call.arguments) .. ")", "LOG")
- LOG.write(log_entry)
- LOG.game_state_before = log_entry.game_state_after
- end
- end
-
- ---@diagnostic disable-next-line: undefined-field
- previous_orders[self.config.type] = current_order
-
- return result
- else
- -- For non-hand/joker card areas, just call the original function
- return original_function(self, ...)
- end
- end
- sendInfoMessage("Hooked into CardArea:align_cards for card rearrange logging", "LOG")
-end
-
--- -----------------------------------------------------------------------------
--- sell_joker Hook
--- -----------------------------------------------------------------------------
-
----Hooks into G.FUNCS.sell_card to detect sell_joker and sell_consumable actions
-function hook_sell_card()
- local original_function = G.FUNCS.sell_card
- G.FUNCS.sell_card = function(e)
- local card = e.config.ref_table
- if card then
- -- Check if the card being sold is a joker from G.jokers
- if card.area == G.jokers then
- -- Find the joker index in G.jokers.cards
- for i, joker in ipairs(G.jokers.cards) do
- if joker == card then
- local function_call = { name = "sell_joker", arguments = { index = i - 1 } } -- 0-based index
- LOG.schedule_write(function_call)
- break
- end
- end
- -- Check if the card being sold is a consumable from G.consumeables
- elseif card.area == G.consumeables then
- -- Find the consumable index in G.consumeables.cards
- for i, consumable in ipairs(G.consumeables.cards) do
- if consumable == card then
- local function_call = { name = "sell_consumable", arguments = { index = i - 1 } } -- 0-based index
- LOG.schedule_write(function_call)
- break
- end
- end
- end
- end
- return original_function(e)
- end
- sendDebugMessage("Hooked into G.FUNCS.sell_card for sell_joker and sell_consumable logging", "LOG")
-end
-
--- TODO: add hooks for other shop functions
-
--- =============================================================================
--- Initializer
--- =============================================================================
-
----Initializes the logger by setting up hooks
-function LOG.init()
- -- Get mod path (required)
- if SMODS.current_mod and SMODS.current_mod.path then
- LOG.mod_path = SMODS.current_mod.path
- sendInfoMessage("Using mod path: " .. LOG.mod_path, "LOG")
- else
- sendErrorMessage("SMODS.current_mod.path not available - LOG disabled", "LOG")
- return
- end
-
- -- Hook into the API update loop to process pending logs
- if API and API.update then
- local original_api_update = API.update
- ---@diagnostic disable-next-line: duplicate-set-field
- API.update = function(dt)
- original_api_update(dt)
- LOG.update()
- end
- sendDebugMessage("Hooked into API.update for pending log processing", "LOG")
- else
- sendErrorMessage("API not available - pending log processing disabled", "LOG")
- end
-
- -- Init hooks
- hook_go_to_menu()
- hook_start_run()
- hook_select_blind()
- hook_skip_blind()
- hook_play_cards_from_highlighted()
- hook_discard_cards_from_highlighted()
- hook_cash_out()
- hook_toggle_shop()
- hook_buy_card()
- hook_use_card()
- hook_reroll_shop()
- hook_hand_rearrange()
- hook_sell_card()
-
- sendInfoMessage("Logger initialized", "LOG")
-end
-
----@type Log
-return LOG
diff --git a/src/lua/settings.lua b/src/lua/settings.lua
index aef0eba..e19f2ed 100644
--- a/src/lua/settings.lua
+++ b/src/lua/settings.lua
@@ -1,247 +1,244 @@
--- Environment Variables
-local headless = os.getenv("BALATROBOT_HEADLESS") == "1"
-local fast = os.getenv("BALATROBOT_FAST") == "1"
-local audio = os.getenv("BALATROBOT_AUDIO") == "1"
-local render_on_api = os.getenv("BALATROBOT_RENDER_ON_API") == "1"
-local port = os.getenv("BALATROBOT_PORT")
-local host = os.getenv("BALATROBOT_HOST")
-
-SETTINGS = {}
-
--- BalatroBot Configuration
-local config = {
- dt = headless and (4.99 / 60.0) or (1.0 / 60.0),
- headless = headless,
- fast = fast,
- audio = audio,
- render_on_api = render_on_api,
-}
+--[[
+BalatroBot configure settings in Balatro using the following environment variables:
--- Apply Love2D patches for performance
-local function apply_love_patches()
- local original_update = love.update
- ---@diagnostic disable-next-line: duplicate-set-field
- love.update = function(_)
- original_update(config.dt)
- end
-end
+ - BALATROBOT_HOST: the hostname when the TCP server is running.
+ Type string (default: 127.0.0.1)
--- Configure Balatro G globals for speed
-local function configure_balatro_speed()
- -- Skip intro and splash screens
- G.SETTINGS.skip_splash = "Yes"
- G.F_SKIP_TUTORIAL = true
+ - BALATROBOT_PORT: the port when the TCP server is running.
+ Type string (default: 12346)
- -- Configure audio based on --audio flag
- if config.audio then
- -- Enable audio when --audio flag is used
- 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
- else
- -- Disable audio by default
- G.SETTINGS.SOUND.volume = 0
- G.SETTINGS.SOUND.music_volume = 0
- G.SETTINGS.SOUND.game_sounds_volume = 0
- G.F_MUTE = true
- end
+ - BALATROBOT_HEADLESS: whether to run in headless mode.
+ 1 for actiavate the headeless mode, 0 for running headed (default: 0)
- if config.fast then
- -- Disable VSync completely
- love.window.setVSync(0)
-
- -- Fast mode settings
- G.FPS_CAP = nil -- Unlimited FPS
- G.SETTINGS.GAMESPEED = 10 -- 10x game speed
- G.ANIMATION_FPS = 60 -- 6x faster animations
-
- -- Disable visual effects
- G.SETTINGS.reduced_motion = true -- Enable reduced motion in fast mode
- G.SETTINGS.screenshake = false
- G.VIBRATION = 0
- 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 = 1 -- Always disable pixel art smoothing
- G.SETTINGS.rumble = false
- G.F_RUMBLE = nil
-
- -- Performance optimizations
- G.F_ENABLE_PERF_OVERLAY = false
- G.SETTINGS.WINDOW.vsync = 0
- G.F_SOUND_THREAD = config.audio -- Enable sound thread only if audio is enabled
- G.F_VERBOSE = false
-
- sendInfoMessage("BalatroBot: Running in fast mode")
- else
- -- Normal mode settings (defaults)
- -- Enable VSync
- love.window.setVSync(1)
-
- -- Performance settings
- G.FPS_CAP = 60
- G.SETTINGS.GAMESPEED = 4 -- Who plays at 1x speed?
- G.ANIMATION_FPS = 10
- G.VIBRATION = 0
-
- -- Feature flags - restore defaults from globals.lua
- G.F_ENABLE_PERF_OVERLAY = false
- G.F_MUTE = not config.audio -- Mute if audio is disabled
- G.F_SOUND_THREAD = config.audio -- Enable sound thread only if audio is enabled
- G.F_VERBOSE = true
- G.F_RUMBLE = nil
-
- -- Audio settings - only restore if audio is enabled
- if config.audio then
- 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
- end
+ - 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)
- -- Graphics settings - restore normal quality
- 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 = 1 -- Always disable pixel art smoothing
+ - BALATROBOT_AUDIO: whether to play audio.
+ 1 for actiavate the audio mode, 0 for no audio (default: 0)
- -- Window settings - restore normal display
- G.SETTINGS.WINDOW = G.SETTINGS.WINDOW or {}
- G.SETTINGS.WINDOW.vsync = 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)
- -- Visual effects - enable reduced motion
- G.SETTINGS.reduced_motion = true -- Always enable reduced motion
- G.SETTINGS.screenshake = true
- G.SETTINGS.rumble = G.F_RUMBLE
+ - BALATROBOT_NO_SHADERS: whether to disable all shaders for better performance.
+ 1 for disable shaders, 0 for enable shaders (default: 0)
+]]
- -- Skip intro but allow normal game flow
- G.SETTINGS.skip_splash = "Yes"
+---@diagnostic disable: duplicate-set-field
- sendInfoMessage("BalatroBot: Running in normal mode")
+---@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,
+}
+
+---@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)
end
+ sendDebugMessage("Patched love.update with dt=" .. dt, "BB.SETTINGS")
end
--- Configure headless mode optimizations
-local function configure_headless()
- if not config.headless then
- return
- 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 = 60
+ G.SETTINGS.GAMESPEED = 4
+ G.ANIMATION_FPS = 10
+
+ -- 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 = 1 -- Always disable pixel art smoothing
+
+ -- visuals
+ G.SETTINGS.skip_splash = "Yes" -- Skip intro animation
+ G.SETTINGS.reduced_motion = true -- Always enable reduced motion
+ G.SETTINGS.screenshake = false
+ G.SETTINGS.rumble = nil
+
+ -- Window
+ love.window.setVSync(0)
+ G.SETTINGS.WINDOW = G.SETTINGS.WINDOW or {}
+ G.SETTINGS.WINDOW.vsync = 0
+end
- -- Hide the window instead of closing it
+--- Configures headless mode by minimizing and hiding the window
+--- Disables all rendering operations, graphics, and window updates
+---@return nil
+local function configure_headless()
if love.window and love.window.isOpen() then
- -- Try to minimize the window
if love.window.minimize then
love.window.minimize()
- sendInfoMessage("BalatroBot: Minimized SMODS loading window")
+ sendDebugMessage("Minimized window", "BB.SETTINGS")
end
- -- Set window to smallest possible size and move it off-screen
+
love.window.setMode(1, 1)
love.window.setPosition(-1000, -1000)
- sendInfoMessage("BalatroBot: Hidden SMODS loading window")
+ sendDebugMessage("Set window to 1x1 and moved to (-1000, -1000)", "BB.SETTINGS")
end
-- Disable all rendering operations
- ---@diagnostic disable-next-line: duplicate-set-field
love.graphics.isActive = function()
return false
end
-- Disable drawing operations
- ---@diagnostic disable-next-line: duplicate-set-field
love.draw = function()
-- Do nothing in headless mode
end
-- Disable graphics present/swap buffers
- ---@diagnostic disable-next-line: duplicate-set-field
love.graphics.present = function()
-- Do nothing in headless mode
end
-- Disable window creation/updates for future calls
if love.window then
- ---@diagnostic disable-next-line: duplicate-set-field
love.window.setMode = function()
- -- Return false to indicate window creation failed (headless)
return false
end
- ---@diagnostic disable-next-line: duplicate-set-field
love.window.isOpen = function()
return false
end
- ---@diagnostic disable-next-line: duplicate-set-field
love.graphics.isCreated = function()
return false
end
end
- -- Log headless mode activation
- sendInfoMessage("BalatroBot: Headless mode enabled - graphics rendering disabled")
+ sendDebugMessage("Headless mode enabled", "BB.SETTINGS")
end
--- Configure on-demand rendering (render only when API calls are made)
+--- 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()
- if not config.render_on_api then
- return
- end
+ BB_RENDER = false
- -- Global flag to trigger rendering
- G.BALATROBOT_SHOULD_RENDER = false
+ -- Original render function
+ local love_draw = love.draw
+ local love_graphics_present = love.graphics.present
- -- Store original rendering functions
- local original_draw = love.draw
- local original_present = love.graphics.present
local did_render_this_frame = false
- -- Replace love.draw to only render when flag is set
- ---@diagnostic disable-next-line: duplicate-set-field
love.draw = function()
- if G.BALATROBOT_SHOULD_RENDER then
- original_draw()
+ if BB_RENDER then
+ love_draw()
did_render_this_frame = true
- G.BALATROBOT_SHOULD_RENDER = false
+ BB_RENDER = false
else
did_render_this_frame = false
end
end
- -- Replace love.graphics.present to only present when rendering happened
- ---@diagnostic disable-next-line: duplicate-set-field
love.graphics.present = function()
if did_render_this_frame then
- original_present()
+ love_graphics_present()
did_render_this_frame = false
end
end
- sendInfoMessage("BalatroBot: Render-on-API mode enabled - frames only on API calls")
+ 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
--- Main setup function
-SETTINGS.setup = function()
- -- Validate mutually exclusive options
- if config.headless and config.render_on_api then
- sendErrorMessage("--headless and --render-on-api are mutually exclusive. Choose one rendering mode.", "SETTINGS")
- error("Configuration error: mutually exclusive rendering modes specified")
+--- 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
- G.BALATROBOT_PORT = port or "12346"
- G.BALATROBOT_HOST = host or "127.0.0.1"
+--- 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
+end
- -- Apply Love2D performance patches
- apply_love_patches()
+--- 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
+ end
- -- Configure Balatro speed settings
- configure_balatro_speed()
+ if BB_SETTINGS.headless then
+ configure_headless()
+ end
+
+ if BB_SETTINGS.render_on_api then
+ configure_render_on_api()
+ end
- -- Apply headless optimizations if needed
- configure_headless()
+ if BB_SETTINGS.fast then
+ configure_fast()
+ end
- -- Apply render-on-API optimizations if needed
- configure_render_on_api()
+ if BB_SETTINGS.no_shaders then
+ configure_no_shaders()
+ end
+
+ if BB_SETTINGS.audio then
+ configure_audio()
+ end
end
diff --git a/src/lua/types.lua b/src/lua/types.lua
deleted file mode 100644
index 31ab64d..0000000
--- a/src/lua/types.lua
+++ /dev/null
@@ -1,373 +0,0 @@
----@meta balatrobot-types
----Type definitions for the BalatroBot Lua mod
-
--- =============================================================================
--- TCP Socket Types
--- =============================================================================
-
----@class TCPSocket
----@field settimeout fun(self: TCPSocket, timeout: number)
----@field setsockname fun(self: TCPSocket, address: string, port: number): boolean, string?
----@field receivefrom fun(self: TCPSocket, size: number): string?, string?, number?
----@field sendto fun(self: TCPSocket, data: string, address: string, port: number): number?, string?
-
--- =============================================================================
--- API Request Types (used in api.lua)
--- =============================================================================
-
----@class PendingRequest
----@field condition fun(): boolean Function that returns true when the request condition is met
----@field action fun() Function to execute when condition is met
----@field args? table Optional arguments passed to the request
-
----@class APIRequest
----@field name string The name of the API function to call
----@field arguments table The arguments to pass to the function
-
----@class ErrorResponse
----@field error string The error message
----@field error_code string Standardized error code (e.g., "E001")
----@field state any The current game state
----@field context? table Optional additional context about the error
-
----@class SaveInfoResponse
----@field profile_path string|nil Current profile path (e.g., "3")
----@field save_directory string|nil Full path to Love2D save directory
----@field save_file_path string|nil Full OS-specific path to save.jkr file
----@field has_active_run boolean Whether a run is currently active
----@field save_exists boolean Whether a save file exists
-
----@class StartRunArgs
----@field deck string The deck name to use
----@field stake? number The stake level (optional)
----@field seed? string The seed for the run (optional)
----@field challenge? string The challenge name (optional)
----@field log_path? string The full file path for the run log (optional, must include .jsonl extension)
-
--- =============================================================================
--- Game Action Argument Types (used in api.lua)
--- =============================================================================
-
----@class BlindActionArgs
----@field action "select" | "skip" The action to perform on the blind
-
----@class HandActionArgs
----@field action "play_hand" | "discard" The action to perform
----@field cards number[] Array of card indices (0-based)
-
----@class RearrangeHandArgs
----@field action "rearrange" The action to perform
----@field cards number[] Array of card indices for every card in hand (0-based)
-
----@class RearrangeJokersArgs
----@field jokers number[] Array of joker indices for every joker (0-based)
-
----@class RearrangeConsumablesArgs
----@field consumables number[] Array of consumable indices for every consumable (0-based)
-
----@class ShopActionArgs
----@field action "next_round" | "buy_card" | "reroll" | "redeem_voucher" | "buy_and_use_card" The action to perform
----@field index? number The index of the card to act on (buy, buy_and_use, redeem, open) (0-based)
-
--- TODO: add the other action "open_pack"
-
----@class SellJokerArgs
----@field index number The index of the joker to sell (0-based)
-
----@class SellConsumableArgs
----@field index number The index of the consumable to sell (0-based)
-
----@class UseConsumableArgs
----@field index number The index of the consumable to use (0-based)
----@field cards? number[] Optional array of card indices to target (0-based)
-
----@class LoadSaveArgs
----@field save_path string Path to the save file relative to Love2D save directory (e.g., "3/save.jkr")
-
--- =============================================================================
--- Main API Module (defined in api.lua)
--- =============================================================================
-
----Main API module for handling TCP communication with bots
----@class API
----@field socket? TCPSocket TCP socket instance
----@field functions table Map of API function names to their implementations
----@field pending_requests table Map of pending async requests
----@field last_client_ip? string IP address of the last client that sent a message
----@field last_client_port? number Port of the last client that sent a message
-
--- =============================================================================
--- Game Entity Types
--- =============================================================================
-
--- Root game state response (G object)
----@class G
----@field state any Current game state enum value
----@field game? GGame Game information (null if not in game)
----@field hand? GHand Hand information (null if not available)
----@field jokers GJokers Jokers area object (with sub-field `cards`)
----@field consumables GConsumables Consumables area object
----@field shop_jokers? GShopJokers Shop jokers area
----@field shop_vouchers? GShopVouchers Shop vouchers area
----@field shop_booster? GShopBooster Shop booster packs area
-
--- Game state (G.GAME)
----@class GGame
----@field bankrupt_at number Money threshold for bankruptcy
----@field base_reroll_cost number Base cost for rerolling shop
----@field blind_on_deck string Current blind type ("Small", "Big", "Boss")
----@field bosses_used GGameBossesUsed Bosses used in run (bl_ = 1|0)
----@field chips number Current chip count
----@field current_round GGameCurrentRound Current round information
----@field discount_percent number Shop discount percentage
----@field dollars number Current money amount
----@field hands_played number Total hands played in the run
----@field inflation number Current inflation rate
----@field interest_amount number Interest amount per dollar
----@field interest_cap number Maximum interest that can be earned
----@field last_blind GGameLastBlind Last blind information
----@field max_jokers number Maximum number of jokers in card area
----@field planet_rate number Probability for planet cards in shop
----@field playing_card_rate number Probability for playing cards in shop
----@field previous_round GGamePreviousRound Previous round information
----@field probabilities GGameProbabilities Various game probabilities
----@field pseudorandom GGamePseudorandom Pseudorandom seed data
----@field round number Current round number
----@field round_bonus GGameRoundBonus Round bonus information
----@field round_scores GGameRoundScores Round scoring data
----@field seeded boolean Whether the run uses a seed
----@field selected_back GGameSelectedBack Selected deck information
----@field shop GGameShop Shop configuration
----@field skips number Number of skips used
----@field smods_version string SMODS version
----@field stake number Current stake level
----@field starting_params GGameStartingParams Starting parameters
----@field tags GGameTags[] Array of tags
----@field tarot_rate number Probability for tarot cards in shop
----@field uncommon_mod number Modifier for uncommon joker probability
----@field unused_discards number Unused discards from previous round
----@field used_vouchers table Vouchers used in run
----@field voucher_text string Voucher text display
----@field win_ante number Ante required to win
----@field won boolean Whether the run is won
-
--- Game tags (G.GAME.tags[])
----@class GGameTags
----@field key string Tag ID (e.g., "tag_foil")
----@field name string Tag display name (e.g., "Foil Tag")
-
--- Last blind info (G.GAME.last_blind)
----@class GGameLastBlind
----@field boss boolean Whether the last blind was a boss
----@field name string Name of the last blind
-
--- Current round info (G.GAME.current_round)
----@class GGameCurrentRound
----@field discards_left number Number of discards remaining
----@field discards_used number Number of discards used
----@field hands_left number Number of hands remaining
----@field hands_played number Number of hands played
----@field reroll_cost number Current dollar cost to reroll the shop offer
----@field free_rerolls number Free rerolls remaining this round
----@field voucher GGameCurrentRoundVoucher Vouchers for this round
-
--- Selected deck info (G.GAME.selected_back)
----@class GGameSelectedBack
----@field name string Name of the selected deck
-
--- Shop configuration (G.GAME.shop)
----@class GGameShop
-
--- Starting parameters (G.GAME.starting_params)
----@class GGameStartingParams
-
--- Previous round info (G.GAME.previous_round)
----@class GGamePreviousRound
-
--- Game probabilities (G.GAME.probabilities)
----@class GGameProbabilities
-
--- Pseudorandom data (G.GAME.pseudorandom)
----@class GGamePseudorandom
-
--- Round bonus (G.GAME.round_bonus)
----@class GGameRoundBonus
-
--- Round scores (G.GAME.round_scores)
----@class GGameRoundScores
-
--- Hand structure (G.hand)
----@class GHand
----@field cards GHandCards[] Array of cards in hand
----@field config GHandConfig Hand configuration
-
--- Hand configuration (G.hand.config)
----@class GHandConfig
----@field card_count number Number of cards in hand
----@field card_limit number Maximum cards allowed in hand
----@field highlighted_limit number Maximum cards that can be highlighted
-
--- Hand card (G.hand.cards[])
----@class GHandCards
----@field label string Display label of the card
----@field sort_id number Unique identifier for this card instance
----@field base GHandCardsBase Base card properties
----@field config GHandCardsConfig Card configuration
----@field debuff boolean Whether card is debuffed
----@field facing string Card facing direction ("front", etc.)
----@field highlighted boolean Whether card is highlighted
-
--- Hand card base properties (G.hand.cards[].base)
----@class GHandCardsBase
----@field id any Card ID
----@field name string Base card name
----@field nominal string Nominal value
----@field original_value string Original card value
----@field suit string Card suit
----@field times_played number Times this card has been played
----@field value string Current card value
-
--- Hand card configuration (G.hand.cards[].config)
----@class GHandCardsConfig
----@field card_key string Unique card identifier
----@field card GHandCardsConfigCard Card-specific data
-
--- Hand card config card data (G.hand.cards[].config.card)
----@class GHandCardsConfigCard
----@field name string Card name
----@field suit string Card suit
----@field value string Card value
-
----@class GCardAreaConfig
----@field card_count number Number of cards currently present in the area
----@field card_limit number Maximum cards allowed in the area
-
----@class GJokers
----@field config GCardAreaConfig Config for jokers card area
----@field cards GJokersCard[] Array of joker card objects
-
----@class GConsumables
----@field config GCardAreaConfig Configuration for the consumables slot
----@field cards? GConsumablesCard[] Array of consumable card objects
-
--- Joker card (G.jokers.cards[])
----@class GJokersCard
----@field label string Display label of the joker
----@field cost number Purchase cost of the joker
----@field sort_id number Unique identifier for this card instance
----@field config GJokersCardConfig Joker card configuration
----@field debuff boolean Whether joker is debuffed
----@field facing string Card facing direction ("front", "back")
----@field highlighted boolean Whether joker is highlighted
-
--- Joker card configuration (G.jokers.cards[].config)
----@class GJokersCardConfig
----@field center_key string Key identifier for the joker center
-
--- Consumable card (G.consumables.cards[])
----@class GConsumablesCard
----@field label string Display label of the consumable
----@field cost number Purchase cost of the consumable
----@field config GConsumablesCardConfig Consumable configuration
-
--- Consumable card configuration (G.consumables.cards[].config)
----@class GConsumablesCardConfig
----@field center_key string Key identifier for the consumable center
-
--- =============================================================================
--- Utility Module
--- =============================================================================
-
----Utility functions for game state extraction and data processing
----@class utils
----@field get_game_state fun(): G Extracts the current game state
----@field table_to_json fun(obj: any, depth?: number): string Converts a Lua table to JSON string
-
--- =============================================================================
--- Log Types (used in log.lua)
--- =============================================================================
-
----@class Log
----@field mod_path string? Path to the mod directory for log file storage
----@field current_run_file string? Path to the current run's log file
----@field pending_logs table Map of pending log entries awaiting conditions
----@field game_state_before G Game state before function call
----@field init fun() Initializes the logger by setting up hooks
----@field write fun(log_entry: LogEntry) Writes a log entry to the JSONL file
----@field update fun() Processes pending logs by checking completion conditions
----@field schedule_write fun(function_call: FunctionCall) Schedules a log entry to be written when condition is met
-
----@class PendingLog
----@field log_entry table The log entry data to be written when condition is met
----@field condition function Function that returns true when the log should be written
-
----@class FunctionCall
----@field name string The name of the function being called
----@field arguments table The parameters passed to the function
-
----@class LogEntry
----@field timestamp_ms_before number Timestamp in milliseconds since epoch before function call
----@field timestamp_ms_after number? Timestamp in milliseconds since epoch after function call
----@field function FunctionCall Function call information
----@field game_state_before G Game state before function call
----@field game_state_after G? Game state after function call
-
--- =============================================================================
--- Configuration Types (used in balatrobot.lua)
--- =============================================================================
-
----@class BalatrobotConfig
----@field port string Port for the bot to listen on
----@field dt number Tells the game that every update is dt seconds long
----@field max_fps integer? Maximum frames per second
----@field vsync_enabled boolean Whether vertical sync is enabled
-
--- =============================================================================
--- Shop Area Types
--- =============================================================================
-
--- Shop jokers area
----@class GShopJokers
----@field config GCardAreaConfig Configuration for the shop jokers area
----@field cards? GShopCard[] Array of shop card objects
-
--- Shop vouchers area
----@class GShopVouchers
----@field config GCardAreaConfig Configuration for the shop vouchers area
----@field cards? GShopCard[] Array of shop voucher objects
-
--- Shop booster area
----@class GShopBooster
----@field config GCardAreaConfig Configuration for the shop booster area
----@field cards? GShopCard[] Array of shop booster objects
-
--- Shop card
----@class GShopCard
----@field label string Display label of the shop card
----@field cost number Purchase cost of the card
----@field sell_cost number Sell cost of the card
----@field debuff boolean Whether card is debuffed
----@field facing string Card facing direction ("front", "back")
----@field highlighted boolean Whether card is highlighted
----@field ability GShopCardAbility Card ability information
----@field config GShopCardConfig Shop card configuration
-
--- Shop card ability (G.shop_*.cards[].ability)
----@class GShopCardAbility
----@field set string The set of the card: "Joker", "Planet", "Tarot", "Spectral", "Voucher", "Booster", or "Consumable"
-
--- Shop card configuration (G.shop_*.cards[].config)
----@class GShopCardConfig
----@field center_key string Key identifier for the card center
-
--- =============================================================================
--- Additional Game State Types
--- =============================================================================
-
--- Round voucher (G.GAME.current_round.voucher)
----@class GGameCurrentRoundVoucher
--- This is intentionally empty as the voucher table structure varies
-
--- Bosses used (G.GAME.bosses_used)
----@class GGameBossesUsed
--- Dynamic table with boss name keys mapping to 1|0
diff --git a/src/lua/utils.lua b/src/lua/utils.lua
deleted file mode 100644
index 9de18a9..0000000
--- a/src/lua/utils.lua
+++ /dev/null
@@ -1,1136 +0,0 @@
----Utility functions for game state extraction and data processing
-utils = {}
-local json = require("json")
-local socket = require("socket")
-
--- ==========================================================================
--- Game State Extraction
---
--- In the following code there are a lot of comments which document the
--- process of understanding the game state. There are many fields that
--- we are not interested for BalatroBot. I leave the comments here for
--- future reference.
---
--- The proper documnetation of the game state is available in the types.lua
--- file (src/lua/types.lua).
--- ==========================================================================
-
----Extracts the current game state including game info, hand, and jokers
----@return G game_state The complete game state
-function utils.get_game_state()
- local game = nil
- if G.GAME then
- local tags = {}
- if G.GAME.tags then
- for i, tag in pairs(G.GAME.tags) do
- tags[i] = {
- -- There are a couples of fieds regarding UI. we are not intersted in that.
- -- HUD_tag = table/list, -- ??
- -- ID = int -- id used in the UI or tag id?
- -- ability = table/list, -- ??
- -- config = table/list, -- ??
- key = tag.key, -- id string of the tag (e.g. "tag_foil")
- name = tag.name, -- text string of the tag (e.g. "Foil Tag")
- -- pos = table/list, coords of the tags in the UI
- -- tag_sprite = table/list, sprite of the tag for the UI
- -- tally = int (default 0), -- ??
- -- triggered = bool (default false), -- false when the tag will be trigger in later stages.
- -- For exaple double money trigger instantly and it's not even add to the tags talbe,
- -- while other tags trigger in the next shop phase.
- }
- end
- end
-
- local last_blind = {
- boss = false,
- name = "",
- }
- if G.GAME.last_blind then
- last_blind = {
- boss = G.GAME.last_blind.boss, -- bool. True if the last blind was a boss
- name = G.GAME.last_blind.name, -- str (default "" before entering round 1)
- -- When entering round 1, the last blind is set to "Small Blind".
- -- So I think that the last blind refers to the blind selected in the most recent BLIND_SELECT state.
- }
- end
- local hands = {}
- for name, hand in pairs(G.GAME.hands) do
- hands[name] = {
- -- visible = hand.visible, -- bool. Whether hand is unlocked/visible to player
- order = hand.order, -- int. Ranking order (1=best, 12=worst)
- mult = hand.mult, -- int. Current multiplier value
- chips = hand.chips, -- int. Current chip value
- s_mult = hand.s_mult, -- int. Starting multiplier
- s_chips = hand.s_chips, -- int. Starting chips
- level = hand.level, -- int. Current level of the hand
- l_mult = hand.l_mult, -- int. Multiplier gained per level
- l_chips = hand.l_chips, -- int. Chips gained per level
- played = hand.played, -- int. Total times played
- played_this_round = hand.played_this_round, -- int. Times played this round
- example = hand.example, -- example of the hand in the format:
- -- {{ "SUIT_RANK", boolean }, { "SUIT_RANK", boolean }, ...}
- -- "SUIT_RANK" is a string, e.g. "S_A" or "H_4" (
- -- - Suit prefixes:
- -- - S_ = Spades ♠
- -- - H_ = Hearts ♥
- -- - D_ = Diamonds ♦
- -- - C_ = Clubs ♣
- -- - Rank suffixes:
- -- - A = Ace
- -- - 2-9 = Number cards
- -- - T = Ten (10)
- -- - J = Jack
- -- - Q = Queen
- -- - K = King
- -- boolean is whether the card is part of the scoring hand
- }
- end
- game = {
- -- STOP_USE = int (default 0), -- ??
- bankrupt_at = G.GAME.bankrupt_at,
- -- banned_keys = table/list, -- ??
- base_reroll_cost = G.GAME.base_reroll_cost,
-
- -- blind = {}, This is active during the playing phase and contains
- -- information about the UI of the blind object. It can be dragged around
- -- We are not interested in it.
-
- blind_on_deck = G.GAME.blind_on_deck, -- Small | ?? | ??
- bosses_used = {
- -- bl_ = int, 1 | 0 (default 0)
- -- ... x 28
- -- In a normal ante there should be only one boss used, so only one value is one
- },
- -- cards_played: table, change during game phase
-
- chips = G.GAME.chips,
- -- chip_text = str, the text of the current chips in the UI
- -- common_mod = int (default 1), -- prob that a common joker appear in the shop
-
- -- "consumeable_buffer": int, (default 0) -- number of cards in the consumeable buffer?
- -- consumeable_usage = { }, -- table/list to track the consumable usage through the run.
- -- "current_boss_streak": int, (default 0) -- in the simple round should be == to the ante?
- --
-
- current_round = {
- -- "ancient_card": { -- maybe some random card used by some joker/effect? idk
- -- "suit": "Spades" | "Hearts" | "Diamonds" | "Clubs",
- -- },
- -- any_hand_drawn = true, -- bool (default true) ??
- -- "cards_flipped": int, (Defualt 0)
- -- "castle_card": { -- ??
- -- "suit": "Spades" | "Hearts" | "Diamonds" | "Clubs",
- -- },
-
- -- This should contains interesting info during playing phase
- -- "current_hand": {
- -- "chip_text": str, Default "-"
- -- "chip_total": int, Default 0
- -- "chip_total_text: str , Default ""
- -- "chips": int, Default 0
- -- "hand_level": str Default ""
- -- "handname": str Default ""
- -- "handname_text": str Default ""
- -- "mult": int, Default 0
- -- "mult_text": str, Default "0"
- -- },
-
- discards_left = G.GAME.current_round.discards_left, -- Number of discards left for this round
- discards_used = G.GAME.current_round.discards_used, -- int (default 0) Number of discard used in this round
-
- --"dollars": int, (default 0) -- maybe dollars earned in this round?
- -- "dollars_to_be_earned": str, (default "") -- ??
- -- "free_rerolls": int, (default 0) -- Number of free rerolls in the shop?
- hands_left = G.GAME.current_round.hands_left, -- Number of hands left for this round
- hands_played = G.GAME.current_round.hands_played, -- Number of hands played in this round
-
- -- Reroll information (used in shop state)
- reroll_cost = G.GAME.current_round.reroll_cost, -- Current cost for a shop reroll
- free_rerolls = G.GAME.current_round.free_rerolls, -- Free rerolls remaining this round
- -- "idol_card": { -- what's a idol card?? maybe some random used by some joker/effect? idk
- -- "rank": "Ace" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "Jack" | "Queen" | "King",
- -- "suit": "Spades" | "Hearts" | "Diamonds" | "Clubs",
- -- },
- -- "jokers_purchased": int, (default 0) -- Number of jokers purchased in this round ?
- -- "mail_card": { -- what's a mail card?? maybe some random used by some joker/effect? idk
- -- "id": int -- id of the mail card
- -- "rank": str, --
- -- }
- -- "most_played_poker_hand": str, (Default "High Card")
- -- "reroll_cost": int, (default 5)
- -- "reroll_cost_increase": int, (default 0)
- -- "round_dollars": int, (default 0) ??
- -- "round_text": str, (default "Round ")
- -- "used_packs": table/list,
- voucher = { -- this is a list cuz some effect can give multiple vouchers per ante
- -- "1": "v_hone",
- -- "spawn": {
- -- "v_hone": "..."
- -- }
- },
- },
-
- -- "disabled_ranks" = table/list, -- there are some boss that disable certain ranks
- -- "disabled_suits" = table/list, -- there are some boss that disable certain suits
-
- discount_percent = G.GAME.discount_percent, -- int (default 0) this lower the price in the shop. A voucher must be redeemed
- dollars = G.GAME.dollars, -- int , current dollars in the run
-
- -- "ecto_minus": int,
- -- "edition_rate": int, (default 1) -- change the prob. to find a card which is not a base?
- -- "hand_usage": table/list, (default {}) -- maybe track the hand played so far in the run?
-
- hands = hands, -- table of all the hands in the game
- hands_played = G.GAME.hands_played, -- (default 0) hand played in this run
- inflation = G.GAME.inflation, -- (default 0) maybe there are some stakes that increase the prices in the shop ?
- interest_amount = G.GAME.interest_amount, -- (default 1) how much each $ is worth at the eval round stage
- interest_cap = G.GAME.interest_cap, -- (default 25) cap for interest, e.g. 25 dollar means that every each 5 dollar you get one $
-
- -- joker_buffer = int, -- (default 0) ??
- -- joker_rate = int, -- (default 20) prob that a joker appear in the shop
- -- joker_usage = G.GAME.joker_usage, -- list/table maybe a list of jokers used in the run?
- --
- last_blind = last_blind,
- -- legendary_mod = G.GAME.legendary_mod, -- (default 1) maybe the probality/modifier to find a legendary joker in the shop?
-
- max_jokers = G.GAME.max_jokers, --(default 0) the number of held jokers?
-
- -- modifiers = list/table, -- ??
- -- orbital_choices = { -- what's an orbital choice?? This is a list (table with int keys). related to pseudorandom
- -- -- 1: {
- -- -- "Big": "Two Pair",
- -- -- "Boss": "Three of a Kind",
- -- -- "Small": "Full House"
- -- -- }
- -- },
- -- pack_size = G.GAME.pack_size (int default 2), -- number of pack slots ?
- -- perishable_rounds = int (default 5), -- ??
- -- perscribed_bosses = list/table, -- ??
-
- planet_rate = G.GAME.planet_rate, -- (int default 4) -- prob that a planet card appers in the shop
- playing_card_rate = G.GAME.playing_card_rate, -- (int default 0) -- prob that a playing card appers in the shop. at the start of the run playable cards are not purchasable so it's 0, then by reedming a voucher, you can buy them in the shop.
- -- pool_flags = list/table, -- ??
-
- previous_round = {
- -- I think that this table will contain the previous round info
- -- "dollars": int, (default 4, this is the dollars amount when starting red deck white stake)
- },
- probabilities = {
- -- Maybe this table track various probabilities for various events (e.g. prob that planet cards appers in the
- -- shop)
- -- "normal": int, (default 1)
- },
-
- -- This table contains the seed used to start a run. The seed is used in the generation of pseudorandom number
- -- which themselves are used to add randomness to a run. (e.g. which is the first tag? well the float that is
- -- probably used to extract the tag for the first round is in Tag1.)
- pseudorandom = {
- -- float e.g. 0.1987752917732 (all the floats are in the range [0, 1) with 13 digit after the dot.
- -- Tag1 = float,
- -- Voucher1 = float,
- -- Voucher1_resample2 = float,
- -- Voucher1_resample3 = float,
- -- anc1 = float,
- -- boss = float,
- -- cas1 = float,
- -- hashed_seed = float,
- -- idol1 = float,
- -- mail1 = float,
- -- orbital = float,
- -- seed = string, This is the seed used to start a run
- -- shuffle = float,
- },
- -- rare_mod = G.GAME.rare_mod, (int default 1) -- maybe the probality/modifier to find a rare joker in the shop?
- -- rental_rate = int (default 3), -- maybe the probality/modifier to find a rental card in the shop?
- round = G.GAME.round, -- number of the current round. 0 before starting the first rounthe first round
- round_bonus = { -- What's a "round_bonus"? Some bonus given at the end of the round? maybe use in the eval round phase
- -- "discards": int, (default 0) ??
- -- "next_hands": int, (default 0) ??
- },
-
- -- round_resets = table/list, -- const used to reset the round? but should be not relevant for our use case
- round_resets = {
- ante = G.GAME.round_resets.ante or 1, -- number of the current ante (1-8 typically, can go higher)
- },
- round_scores = {
- -- contains values used in the round eval phase?
- -- "cards_discarded": {
- -- "amt": int, (default 0) amount of cards discarded
- -- "label": "Cards Discarded" label for the amount of cards discarded. maybe used in the interface
- -- },
- -- "cards_played": {...}, amount of cards played in this round
- -- "cards_purchased": {...}, amount of cards purchased in this round
- -- "furthest_ante": {...}, furthest ante in this run
- -- "furthest_round": {...}, furthest round in this round or run?
- -- "hand": {...}, best hand in this round
- -- "new_collection": {...}, new cards discovered in this round
- -- "poker_hand": {...}, most played poker hand in this round
- -- "times_rerolled": {...}, number of times rerolled in this round
- },
- seeded = G.GAME.seeded, -- bool if the run use a seed or not
- selected_back = {
- -- The back should be the deck: Red Deck, Black Deck, etc.
- -- This table contains functions and info about deck selection
- -- effect = {} -- contains function e.g. "set"
- -- loc_name = str, -- ?? (default "Red Deck")
- name = G.GAME.selected_back.name, -- name of the deck
- -- pos = {x = int (default 0), y = int (default 0)}, -- ??
- },
- -- seleted_back_key = table -- ??
- shop = {
- -- contains info about the shop
- -- joker_max = int (default 2), -- max number that can appear in the shop or the number of shop slots?
- },
- skips = G.GAME.skips, -- number of skips in this run
- smods_version = G.GAME.smods_version, -- version of smods loaded
- -- sort = str, (default "desc") card sort order. descending (desc) or suit, I guess?
- -- spectral_rate = int (default 0), -- prob that a spectral card appear in the shop
- stake = G.GAME.stake, --int (default 1), -- the stake for the run (1 for White Stake, 2 for Red Stake ...)
- -- starting_deck_size = int (default 52), -- the starting deck size for the run.
- starting_params = {
- -- The starting parmeters are maybe not relevant, we are intersted in
- -- the actual values of the parameters
- --
- -- ante_scaling = G.GAME.starting_params.ante_scaling, -- (default 1) increase the ante by one after boss defeated
- -- boosters_in_shop = G.GAME.starting_params.boosters_in_shop, -- (default 2) Number of booster slots
- -- consumable_slots = G.GAME.starting_params.consumable_slots, -- (default 2) Number of consumable slots
- -- discard_limit = G.GAME.starting_params.discard_limit, -- (default 5) Number of cards to discard
- -- ...
- },
-
- -- tag_tally = -- int (default 0), -- what's a tally?
- tags = tags,
- tarot_rate = G.GAME.tarot_rate, -- int (default 4), -- prob that a tarot card appear in the shop
- uncommon_mod = G.GAME.uncommon_mod, -- int (default 1), -- prob that an uncommon joker appear in the shop
- unused_discards = G.GAME.unused_discards, -- int (default 0), -- number of discards left at the of a round. This is used some time to in the eval round phase
- -- used_jokers = { -- table/list to track the joker usage through the run ?
- -- c_base = bool
- -- }
- used_vouchers = G.GAME.used_vouchers, -- table/list to track the voucher usage through the run. Should be the ones that can be see in "Run Info"
- voucher_text = G.GAME.voucher_text, -- str (default ""), -- the text of the voucher for the current run
- win_ante = G.GAME.win_ante, -- int (default 8), -- the ante for the win condition
- won = G.GAME.won, -- bool (default false), -- true if the run is won (e.g. current ante > win_ante)
- }
- end
-
- local consumables = nil
- if G.consumeables then
- local cards = {}
- if G.consumeables.cards then
- for i, card in pairs(G.consumeables.cards) do
- cards[i] = {
- ability = {
- set = card.ability.set,
- },
- label = card.label,
- description = utils.get_card_ui_description(card),
- cost = card.cost,
- sell_cost = card.sell_cost,
- sort_id = card.sort_id, -- Unique identifier for this card instance (used for rearranging)
- config = {
- center_key = card.config.center_key,
- },
- debuff = card.debuff,
- facing = card.facing,
- highlighted = card.highlighted,
- }
- end
- end
- consumables = {
- cards = cards,
- config = {
- card_count = G.consumeables.config.card_count,
- card_limit = G.consumeables.config.card_limit,
- },
- }
- end
-
- local hand = nil
- if G.hand then
- local cards = {}
- for i, card in pairs(G.hand.cards) do
- cards[i] = {
- ability = {
- set = card.ability.set, -- str. The set of the card: Joker, Planet, Voucher, Booster, or Consumable
- effect = card.ability.effect, -- str. Enhancement type: "Bonus Card", "Mult Card", "Wild Card", "Glass Card", "Steel Card", "Stone Card", "Gold Card", "Lucky Card"
- name = card.ability.name, -- str. Enhancement name: "Bonus Card", "Mult Card", "Wild Card", "Glass Card", "Steel Card", "Stone Card", "Gold Card", "Lucky Card"
- },
- -- ability = table of card abilities effect, mult, extra_value
- label = card.label, -- str (default "Base Card") | ... | ... | ?
- description = utils.get_card_ui_description(card),
- -- playing_card = card.config.card.playing_card, -- int. The card index in the deck for the current round ?
- -- sell_cost = card.sell_cost, -- int (default 1). The dollars you get if you sell this card ?
- sort_id = card.sort_id, -- int. Unique identifier for this card instance
- seal = card.seal, -- str. Seal type: "Red", "Blue", "Gold", "Purple" or nil
- edition = card.edition, -- table. Edition data: {type="foil/holo/polychrome/negative", chips=X, mult=X, x_mult=X} or nil
- base = {
- -- These should be the valude for the original base card
- -- without any modifications
- id = card.base.id, -- ??
- name = card.base.name,
- nominal = card.base.nominal,
- original_value = card.base.original_value,
- suit = card.base.suit,
- times_played = card.base.times_played,
- value = card.base.value,
- },
- config = {
- card_key = card.config.card_key,
- card = {
- name = card.config.card.name,
- suit = card.config.card.suit,
- value = card.config.card.value,
- },
- },
- debuff = card.debuff,
- -- debuffed_by_blind = bool (default false). True if the card is debuffed by the blind
- facing = card.facing, -- str (default "front") | ... | ... | ?
- highlighted = card.highlighted, -- bool (default false). True if the card is highlighted
- }
- end
-
- hand = {
- cards = cards,
- config = {
- card_count = G.hand.config.card_count, -- (int) number of cards in the hand
- card_limit = G.hand.config.card_limit, -- (int) max number of cards in the hand
- highlighted_limit = G.hand.config.highlighted_limit, -- (int) max number of highlighted cards in the hand
- -- lr_padding ?? flaot
- -- sort = G.hand.config.sort, -- (str) sort order of the hand. "desc" | ... | ? not really... idk
- -- temp_limit ?? (int)
- -- type ?? (Default "hand", str)
- },
- -- container = table for UI elements. we are not interested in it
- -- created_on_pause = bool ??
- -- highlighted = list of highlighted cards. This is a list of card.
- -- hover_offset = table/list, coords of the hand in the UI. we are not interested in it.
- -- last_aligned = int, ??
- -- last_moved = int, ??
- --
- -- There a a lot of other fields that we are not interested in ...
- }
- end
-
- local jokers = nil
- if G.jokers then
- local cards = {}
- if G.jokers.cards then
- for i, card in pairs(G.jokers.cards) do
- cards[i] = {
- ability = {
- set = card.ability.set, -- str. The set of the card: Joker, Planet, Voucher, Booster, or Consumable
- },
- label = card.label,
- description = utils.get_card_ui_description(card), -- str. Full computed description text from UI
- cost = card.cost,
- sell_cost = card.sell_cost,
- sort_id = card.sort_id, -- Unique identifier for this card instance (used for rearranging)
- edition = card.edition, -- table. Edition data: {type="foil/holo/polychrome/negative", chips=X, mult=X, x_mult=X} or nil
- config = {
- center_key = card.config.center_key,
- },
- debuff = card.debuff,
- facing = card.facing,
- highlighted = card.highlighted,
- }
- end
- end
- jokers = {
- cards = cards,
- config = {
- card_count = G.jokers.config.card_count,
- card_limit = G.jokers.config.card_limit,
- },
- }
- end
-
- local shop_jokers = nil
- if G.shop_jokers then
- local config = {}
- if G.shop_jokers.config then
- config = {
- card_count = G.shop_jokers.config.card_count, -- int. how many cards are in the the shop
- card_limit = G.shop_jokers.config.card_limit, -- int. how many cards can be in the shop
- }
- end
- local cards = {}
- if G.shop_jokers.cards then
- for i, card in pairs(G.shop_jokers.cards) do
- cards[i] = {
- ability = {
- set = card.ability.set, -- str. The set of the card: Joker, Planet, Voucher, Booster, or Consumable
- effect = card.ability.effect, -- str. Enhancement type (for playing cards only)
- name = card.ability.name, -- str. Enhancement name (for playing cards only)
- },
- config = {
- center_key = card.config.center_key, -- id of the card
- },
- debuff = card.debuff, -- bool. True if the card is a debuff
- cost = card.cost, -- int. The cost of the card
- label = card.label, -- str. The label of the card
- description = utils.get_card_ui_description(card),
- facing = card.facing, -- str. The facing of the card: front | back
- highlighted = card.highlighted, -- bool. True if the card is highlighted
- sell_cost = card.sell_cost, -- int. The sell cost of the card
- seal = card.seal, -- str. Seal type: "Red", "Blue", "Gold", "Purple" (playing cards only) or nil
- edition = card.edition, -- table. Edition data: {type="foil/holo/polychrome/negative", chips=X, mult=X, x_mult=X} (jokers/consumables) or nil
- }
- end
- end
- shop_jokers = {
- config = config,
- cards = cards,
- }
- end
-
- local shop_vouchers = nil
- if G.shop_vouchers then
- local config = {}
- if G.shop_vouchers.config then
- config = {
- card_count = G.shop_vouchers.config.card_count,
- card_limit = G.shop_vouchers.config.card_limit,
- }
- end
- local cards = {}
- if G.shop_vouchers.cards then
- for i, card in pairs(G.shop_vouchers.cards) do
- cards[i] = {
- ability = {
- set = card.ability.set,
- },
- config = {
- center_key = card.config.center_key,
- },
- debuff = card.debuff,
- cost = card.cost,
- label = card.label,
- description = utils.get_card_ui_description(card),
- facing = card.facing,
- highlighted = card.highlighted,
- sell_cost = card.sell_cost,
- }
- end
- end
- shop_vouchers = {
- config = config,
- cards = cards,
- }
- end
-
- local shop_booster = nil
- if G.shop_booster then
- -- NOTE: In the game these are called "packs"
- -- but the variable name is "cards" in the API.
- local config = {}
- if G.shop_booster.config then
- config = {
- card_count = G.shop_booster.config.card_count,
- card_limit = G.shop_booster.config.card_limit,
- }
- end
- local cards = {}
- if G.shop_booster.cards then
- for i, card in pairs(G.shop_booster.cards) do
- cards[i] = {
- ability = {
- set = card.ability.set,
- },
- config = {
- center_key = card.config.center_key,
- },
- cost = card.cost,
- label = card.label,
- description = utils.get_card_ui_description(card),
- highlighted = card.highlighted,
- sell_cost = card.sell_cost,
- }
- end
- end
- shop_booster = {
- config = config,
- cards = cards,
- }
- end
-
- return {
- state = G.STATE,
- game = game,
- hand = hand,
- jokers = jokers,
- shop_jokers = shop_jokers, -- NOTE: This contains all cards in the shop, not only jokers.
- shop_vouchers = shop_vouchers,
- shop_booster = shop_booster,
- consumables = consumables,
- blinds = utils.get_blinds_info(),
- }
-end
-
--- ==========================================================================
--- Blind Information Functions
--- ==========================================================================
-
----Gets comprehensive blind information for the current ante
----@return table blinds Information about small, big, and boss blinds
-function utils.get_blinds_info()
- local blinds = {
- small = {
- name = "Small",
- score = 0,
- status = "Upcoming",
- effect = "",
- tag_name = "",
- tag_effect = "",
- },
- big = {
- name = "Big",
- score = 0,
- status = "Upcoming",
- effect = "",
- tag_name = "",
- tag_effect = "",
- },
- boss = {
- name = "",
- score = 0,
- status = "Upcoming",
- effect = "",
- tag_name = "",
- tag_effect = "",
- },
- }
-
- if not G.GAME or not G.GAME.round_resets then
- return blinds
- end
-
- -- 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
-
- -- Apply ante scaling
- local ante_scaling = G.GAME.starting_params.ante_scaling or 1
-
- -- Small blind (1x multiplier)
- blinds.small.score = math.floor(base_amount * 1 * ante_scaling)
- blinds.small.status = G.GAME.round_resets.blind_states.Small or "Upcoming"
-
- -- Big blind (1.5x multiplier)
- blinds.big.score = math.floor(base_amount * 1.5 * ante_scaling)
- blinds.big.status = G.GAME.round_resets.blind_states.Big or "Upcoming"
-
- -- Boss blind
- local boss_choice = G.GAME.round_resets.blind_choices.Boss
- if boss_choice and G.P_BLINDS[boss_choice] then
- local boss_blind = G.P_BLINDS[boss_choice]
- blinds.boss.name = boss_blind.name or ""
- blinds.boss.score = math.floor(base_amount * (boss_blind.mult or 2) * ante_scaling)
- blinds.boss.status = G.GAME.round_resets.blind_states.Boss or "Upcoming"
-
- -- Get boss effect description
- if boss_blind.key then
- local loc_target = localize({ ---@diagnostic disable-line: undefined-global
- type = "raw_descriptions",
- key = boss_blind.key,
- set = "Blind",
- vars = { "" },
- })
- if loc_target and loc_target[1] then
- blinds.boss.effect = loc_target[1]
- if loc_target[2] then
- blinds.boss.effect = blinds.boss.effect .. " " .. loc_target[2]
- end
- end
- end
- else
- blinds.boss.name = "Boss"
- blinds.boss.score = math.floor(base_amount * 2 * ante_scaling)
- blinds.boss.status = G.GAME.round_resets.blind_states.Boss or "Upcoming"
- end
-
- -- Get tag information for Small and Big blinds
- if G.GAME.round_resets.blind_tags then
- -- Small blind tag
- local small_tag_key = G.GAME.round_resets.blind_tags.Small
- if small_tag_key and G.P_TAGS[small_tag_key] then
- local tag_data = G.P_TAGS[small_tag_key]
- blinds.small.tag_name = tag_data.name or ""
-
- -- Get tag effect description
- local tag_effect = localize({ ---@diagnostic disable-line: undefined-global
- type = "raw_descriptions",
- key = small_tag_key,
- set = "Tag",
- vars = { "" },
- })
- if tag_effect and tag_effect[1] then
- blinds.small.tag_effect = tag_effect[1]
- if tag_effect[2] then
- blinds.small.tag_effect = blinds.small.tag_effect .. " " .. tag_effect[2]
- end
- end
- end
-
- -- Big blind tag
- local big_tag_key = G.GAME.round_resets.blind_tags.Big
- if big_tag_key and G.P_TAGS[big_tag_key] then
- local tag_data = G.P_TAGS[big_tag_key]
- blinds.big.tag_name = tag_data.name or ""
-
- -- Get tag effect description
- local tag_effect = localize({ ---@diagnostic disable-line: undefined-global
- type = "raw_descriptions",
- key = big_tag_key,
- set = "Tag",
- vars = { "" },
- })
- if tag_effect and tag_effect[1] then
- blinds.big.tag_effect = tag_effect[1]
- if tag_effect[2] then
- blinds.big.tag_effect = blinds.big.tag_effect .. " " .. tag_effect[2]
- end
- end
- end
- end
-
- -- Boss blind has no tags (tag_name and tag_effect remain empty strings)
-
- return blinds
-end
-
--- ==========================================================================
--- Cards Effects
--- ==========================================================================
-
----Gets the description text for a card by reading from its UI elements
----@param card table The card object
----@return string description The description text from UI
-function utils.get_card_ui_description(card)
- -- Generate the UI structure (same as hover tooltip)
- card:hover()
- card:stop_hover()
- local ui_table = card.ability_UIBox_table
- if not ui_table then
- return ""
- end
-
- -- Extract all text nodes from the UI tree
- local texts = {}
-
- -- The UI table has main/info/type sections
- if ui_table.main then
- for _, line in ipairs(ui_table.main) do
- local line_texts = {}
- for _, section in ipairs(line) do
- if section.config and section.config.text then
- -- normal text and colored text
- line_texts[#line_texts + 1] = section.config.text
- elseif section.nodes then
- for _, node in ipairs(section.nodes) do
- if node.config and node.config.text then
- -- hightlighted text
- line_texts[#line_texts + 1] = node.config.text
- end
- end
- end
- end
- texts[#texts + 1] = table.concat(line_texts, "")
- end
- end
-
- -- Join text lines with spaces (in the game these are separated by newlines)
- return table.concat(texts, " ")
-end
-
--- ==========================================================================
--- Utility Functions
--- ==========================================================================
-
-function utils.sets_equal(list1, list2)
- if #list1 ~= #list2 then
- return false
- end
-
- local set = {}
- for _, v in ipairs(list1) do
- set[v] = true
- end
-
- for _, v in ipairs(list2) do
- if not set[v] then
- return false
- end
- end
-
- return true
-end
-
--- ==========================================================================
--- Debugging Utilities
--- ==========================================================================
-
----Converts a Lua table to JSON string with depth limiting to prevent infinite recursion
----@param obj any The object to convert to JSON
----@param depth? number Maximum depth to traverse (default: 3)
----@return string JSON string representation of the object
-function utils.table_to_json(obj, depth)
- depth = depth or 3
-
- -- Fields to skip during serialization to avoid circular references and large data
- local skip_fields = {
- children = true,
- parent = true,
- velocity = true,
- area = true,
- alignment = true,
- container = true,
- h_popup = true,
- role = true,
- colour = true,
- back_overlay = true,
- center = true,
- }
-
- local function sanitize_for_json(value, current_depth)
- if current_depth <= 0 then
- return "..."
- end
-
- local value_type = type(value)
-
- if value_type == "nil" then
- return nil
- elseif value_type == "string" or value_type == "number" or value_type == "boolean" then
- return value
- elseif value_type == "function" then
- return "function"
- elseif value_type == "userdata" then
- return "userdata"
- elseif value_type == "table" then
- local sanitized = {}
- for k, v in pairs(value) do
- local key = type(k) == "string" and k or tostring(k)
- -- Skip keys that start with capital letters (UI-related)
- -- Skip predefined fields to avoid circular references and large data
- if not (type(key) == "string" and string.sub(key, 1, 1):match("[A-Z]")) and not skip_fields[key] then
- sanitized[key] = sanitize_for_json(v, current_depth - 1)
- end
- end
- return sanitized
- else
- return tostring(value)
- end
- end
-
- local sanitized = sanitize_for_json(obj, depth)
- return json.encode(sanitized)
-end
-
--- Load DebugPlus integration
--- Attempt to load the optional DebugPlus mod (https://github.com/WilsontheWolf/DebugPlus/tree/master).
--- DebugPlus is a Balatro mod that provides additional debugging utilities for mod development,
--- such as custom debug commands and structured logging. It is not required for core functionality
--- and is primarily intended for development and debugging purposes. If the module is unavailable
--- or incompatible, the program will continue to function without it.
-local success, dpAPI = pcall(require, "debugplus-api")
-
-if success and dpAPI.isVersionCompatible(1) then
- local debugplus = dpAPI.registerID("balatrobot")
- debugplus.addCommand({
- name = "env",
- shortDesc = "Get game state",
- desc = "Get the current game state, useful for debugging",
- exec = function(args, _, _)
- debugplus.logger.log('{"name": "' .. args[1] .. '", "G": ' .. utils.table_to_json(G.GAME, 2) .. "}")
- end,
- })
-end
-
--- ==========================================================================
--- Completion Conditions
--- ==========================================================================
-
--- The threshold for determining when game state transitions are complete.
--- This value represents the maximum number of events allowed in the game's event queue
--- to consider the game idle and waiting for user action. When the queue has fewer than
--- 3 events, the game is considered stable enough to process API responses. This is a
--- heuristic based on empirical testing to ensure smooth gameplay without delays.
-local EVENT_QUEUE_THRESHOLD = 3
-
--- Timestamp storage for delayed conditions
-local condition_timestamps = {}
-
----Completion conditions for different game actions to determine when action execution is complete
----These are shared between API and LOG systems to ensure consistent timing
----@type table
-utils.COMPLETION_CONDITIONS = {
- get_game_state = {
- [""] = function()
- return #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD
- end,
- },
-
- go_to_menu = {
- [""] = function()
- return G.STATE == G.STATES.MENU and G.MAIN_MENU_UI
- end,
- },
-
- start_run = {
- [""] = function()
- return G.STATE == G.STATES.BLIND_SELECT
- and G.GAME.blind_on_deck
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD
- end,
- },
-
- skip_or_select_blind = {
- ["select"] = function()
- if G.GAME and G.GAME.facing_blind and G.STATE == G.STATES.SELECTING_HAND then
- return #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD
- end
- end,
- ["skip"] = function()
- if G.prev_small_state == "Skipped" or G.prev_large_state == "Skipped" or G.prev_boss_state == "Skipped" then
- return #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD
- end
- return false
- end,
- },
-
- play_hand_or_discard = {
- -- TODO: refine condition for be specific about the action
- ["play_hand"] = function()
- if #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE then
- -- round still going
- if G.buttons and G.STATE == G.STATES.SELECTING_HAND then
- return true
- -- round won and entering cash out state (ROUND_EVAL state)
- elseif G.STATE == G.STATES.ROUND_EVAL then
- return true
- -- game over state
- elseif G.STATE == G.STATES.GAME_OVER then
- return true
- end
- end
- return false
- end,
- ["discard"] = function()
- if #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE then
- -- round still going
- if G.buttons and G.STATE == G.STATES.SELECTING_HAND then
- return true
- -- round won and entering cash out state (ROUND_EVAL state)
- elseif G.STATE == G.STATES.ROUND_EVAL then
- return true
- -- game over state
- elseif G.STATE == G.STATES.GAME_OVER then
- return true
- end
- end
- return false
- end,
- },
-
- rearrange_hand = {
- [""] = function()
- return G.STATE == G.STATES.SELECTING_HAND
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD
- and G.STATE_COMPLETE
- end,
- },
-
- rearrange_jokers = {
- [""] = function()
- return #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE
- end,
- },
-
- rearrange_consumables = {
- [""] = function()
- return #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE
- end,
- },
-
- cash_out = {
- [""] = function()
- return G.STATE == G.STATES.SHOP and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE
- end,
- },
-
- shop = {
- buy_card = function()
- local base_condition = G.STATE == G.STATES.SHOP
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduve the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.shop_buy_card = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.shop_buy_card then
- condition_timestamps.shop_buy_card = socket.gettime()
- end
-
- -- Check if 0.1 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.shop_buy_card
- return elapsed > 0.1
- end,
- next_round = function()
- return G.STATE == G.STATES.BLIND_SELECT and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD and G.STATE_COMPLETE
- end,
- buy_and_use_card = function()
- local base_condition = G.STATE == G.STATES.SHOP
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduve the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.shop_buy_and_use = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.shop_buy_and_use then
- condition_timestamps.shop_buy_and_use = socket.gettime()
- end
-
- -- Check if 0.1 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.shop_buy_and_use
- return elapsed > 0.1
- end,
- reroll = function()
- local base_condition = G.STATE == G.STATES.SHOP
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduve the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.shop_reroll = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.shop_reroll then
- condition_timestamps.shop_reroll = socket.gettime()
- end
-
- -- Check if 0.3 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.shop_reroll
- return elapsed > 0.30
- end,
- redeem_voucher = function()
- local base_condition = G.STATE == G.STATES.SHOP
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduve the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.shop_redeem_voucher = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.shop_redeem_voucher then
- condition_timestamps.shop_redeem_voucher = socket.gettime()
- end
-
- -- Check if 0.3 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.shop_redeem_voucher
- return elapsed > 0.10
- end,
- },
- sell_joker = {
- [""] = function()
- local base_condition = #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduce the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.sell_joker = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.sell_joker then
- condition_timestamps.sell_joker = socket.gettime()
- end
-
- -- Check if 0.2 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.sell_joker
- return elapsed > 0.30
- end,
- },
- sell_consumable = {
- [""] = function()
- local base_condition = #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduce the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.sell_consumable = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.sell_consumable then
- condition_timestamps.sell_consumable = socket.gettime()
- end
-
- -- Check if 0.3 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.sell_consumable
- return elapsed > 0.30
- end,
- },
- use_consumable = {
- [""] = function()
- local base_condition = #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD - 1 -- need to reduce the threshold
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.use_consumable = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.use_consumable then
- condition_timestamps.use_consumable = socket.gettime()
- end
-
- -- Check if 0.2 seconds have passed
- local elapsed = socket.gettime() - condition_timestamps.use_consumable
- return elapsed > 0.20
- end,
- },
- load_save = {
- [""] = function()
- local base_condition = G.STATE
- and G.STATE ~= G.STATES.SPLASH
- and G.GAME
- and G.GAME.round
- and #G.E_MANAGER.queues.base < EVENT_QUEUE_THRESHOLD
- and G.STATE_COMPLETE
-
- if not base_condition then
- -- Reset timestamp if base condition is not met
- condition_timestamps.load_save = nil
- return false
- end
-
- -- Base condition is met, start timing
- if not condition_timestamps.load_save then
- condition_timestamps.load_save = socket.gettime()
- end
-
- -- Check if 0.5 seconds have passed (nature of start_run)
- local elapsed = socket.gettime() - condition_timestamps.load_save
- return elapsed > 0.50
- end,
- },
-}
-
-return utils
diff --git a/src/lua/utils/debugger.lua b/src/lua/utils/debugger.lua
new file mode 100644
index 0000000..4a3c262
--- /dev/null
+++ b/src/lua/utils/debugger.lua
@@ -0,0 +1,139 @@
+-- src/lua/utils/debugger.lua
+-- DebugPlus Integration
+--
+-- Attempts to load and configure DebugPlus API for enhanced debugging
+-- Provides logger instance when DebugPlus mod is available
+
+-- Load test endpoints if debug mode is enabled
+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")
+
+-- Helper function to format response as pretty-printed table
+local function format_response(response, depth, indent)
+ depth = depth or 5
+ indent = indent or ""
+
+ if depth == 0 then
+ return tostring(response)
+ end
+
+ if type(response) ~= "table" then
+ return tostring(response)
+ end
+
+ -- Check for custom tostring
+ if (getmetatable(response) or {}).__tostring then
+ return tostring(response)
+ end
+
+ local result = "{\n"
+ local count = 0
+ local max_items = 50 -- Limit items per level to prevent huge output
+
+ for k, v in pairs(response) do
+ -- Skip "hands" key as it clutters the output
+ if k ~= "hands" then
+ count = count + 1
+ if count > max_items then
+ result = result .. indent .. " ... (" .. (count - max_items) .. " more items)\n"
+ break
+ end
+
+ local key_str = tostring(k)
+ local value_str
+
+ if type(v) == "table" then
+ value_str = format_response(v, depth - 1, indent .. " ")
+ else
+ value_str = tostring(v)
+ end
+
+ result = result .. indent .. " " .. key_str .. ": " .. value_str .. "\n"
+ end
+ end
+
+ result = result .. indent .. "}"
+ return result
+end
+
+-- Define BB_API global namespace for calling endpoints via /eval
+-- Usage: /eval BB_API.gamestate({})
+-- Usage: /eval BB_API.start({deck="RED", stake="WHITE"})
+BB_API = setmetatable({}, {
+ __index = function(t, endpoint_name)
+ return function(args)
+ args = args or {}
+
+ -- Check if dispatcher is initialized
+ if not BB_DISPATCHER or not BB_DISPATCHER.endpoints then
+ error("BB_DISPATCHER not initialized")
+ end
+
+ -- Check if endpoint exists
+ if not BB_DISPATCHER.endpoints[endpoint_name] then
+ error("Unknown endpoint: " .. endpoint_name)
+ end
+
+ -- Create request
+ local request = {
+ name = endpoint_name,
+ arguments = args,
+ }
+
+ -- Override send_response to capture and log
+ local original_send_response = BB_DISPATCHER.Server.send_response
+
+ BB_DISPATCHER.Server.send_response = function(response)
+ -- Restore immediately to prevent race conditions
+ BB_DISPATCHER.Server.send_response = original_send_response
+
+ -- Log the response if in debug mode
+ if BB_DEBUG and BB_DEBUG.log then
+ local formatted = format_response(response)
+ local level = response.error and "error" or "info"
+ BB_DEBUG.log[level]("API[" .. endpoint_name .. "] Response:\n" .. formatted)
+ end
+
+ -- Still send to TCP client if connected
+ original_send_response(response)
+ end
+
+ -- Dispatch the request
+ BB_DISPATCHER.dispatch(request)
+
+ return "Dispatched: " .. endpoint_name .. "()"
+ end
+ end,
+})
+
+---@type Debug
+BB_DEBUG = {
+ log = nil,
+}
+--- Initializes DebugPlus integration if available
+--- Registers BalatroBot with DebugPlus and creates logger instance
+---@return nil
+BB_DEBUG.setup = function()
+ local success, dpAPI = pcall(require, "debugplus.api")
+ if not success or not dpAPI then
+ sendDebugMessage("DebugPlus API not found", "BALATROBOT")
+ return
+ end
+ if not dpAPI.isVersionCompatible(1) then
+ sendDebugMessage("DebugPlus API version is not compatible", "BALATROBOT")
+ return
+ end
+ local dp = dpAPI.registerID("BalatroBot")
+ if not dp then
+ sendDebugMessage("Failed to register with DebugPlus", "BALATROBOT")
+ return
+ end
+
+ -- Create a logger
+ BB_DEBUG.log = dp.logger
+ BB_DEBUG.log.debug("DebugPlus API available")
+ BB_DEBUG.log.info("Use /eval BB_API.endpoint_name({args}) to call API endpoints")
+end
diff --git a/src/lua/utils/enums.lua b/src/lua/utils/enums.lua
new file mode 100644
index 0000000..6ff1c59
--- /dev/null
+++ b/src/lua/utils/enums.lua
@@ -0,0 +1,378 @@
+---@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
+
+---@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
+
+---@alias State
+---| "SELECTING_HAND" # 1 When you can select cards to play or discard
+---| "HAND_PLAYED" # 2 Duing hand playing animation
+---| "DRAW_TO_HAND" # 3 During hand drawing animation
+---| "GAME_OVER" # 4 Game is over
+---| "SHOP" # 5 When inside the shop
+---| "PLAY_TAROT" # 6
+---| "BLIND_SELECT" # 7 When in the blind selection phase
+---| "ROUND_EVAL" # 8 When the round end and inside the "cash out" phase
+---| "TAROT_PACK" # 9
+---| "PLANET_PACK" # 10
+---| "MENU" # 11 When in the main menu of the game
+---| "TUTORIAL" # 12
+---| "SPLASH" # 13
+---| "SANDBOX" # 14
+---| "SPECTRAL_PACK" # 15
+---| "DEMO_CTA" # 16
+---| "STANDARD_PACK" # 17
+---| "BUFFOON_PACK" # 18
+---| "NEW_ROUND" # 19 When a round is won and the new round begins
+---| "SMODS_BOOSTER_OPENED" # 999 When a booster pack is opened with SMODS loaded
+---| "UNKNOWN" # Not a number, we never expect this game state
+
+---@alias Card.Set
+---| "BOOSTER" # Booster pack purchasale in the shop
+---| "DEFAULT" # Default playing card
+---| "EDITION" # Card with an edition
+---| "ENHANCED" # Playing card with an enhancement
+---| "JOKER" # Joker card
+---| "TAROT" # Tarot card (consumable)
+---| "PLANET" # Planet card (consumable)
+---| "SPECTRAL" # Spectral card (consumable)
+---| "VOUCHER" # Voucher card
+
+---@alias Card.Value.Suit
+---| "H" # Hearts (playing card)
+---| "D" # Diamonds (playing card)
+---| "C" # Clubs (playing card)
+---| "S" # Spades (playing card)
+
+---@alias Card.Value.Rank
+---| "2" # Two (playing card)
+---| "3" # Three (playing card)
+---| "4" # Four (playing card)
+---| "5" # Five (playing card)
+---| "6" # Six (playing card)
+---| "7" # Seven (playing card)
+---| "8" # Eight (playing card)
+---| "9" # Nine (playing card)
+---| "T" # Ten (playing card)
+---| "J" # Jack (playing card)
+---| "Q" # Queen (playing card)
+---| "K" # King (playing card)
+---| "A" # Ace (playing card)
+
+---@alias Card.Key.Consumable.Tarot
+---| "c_fool" # The Fool: Creates the last Tarot or Planet card used during this run (The Fool excluded)
+---| "c_magician" # The Magician: Enhances 2 selected cards to Lucky Cards
+---| "c_high_priestess" # The High Priestess: Creates up to 2 random Planet cards (Must have room)
+---| "c_empress" # The Empress: Enhances 2 selected cards to Mult Cards
+---| "c_emperor" # The Emperor: Creates up to 2 random Tarot cards (Must have room)
+---| "c_heirophant" # The Hierophant: Enhances 2 selected cards to Bonus Cards
+---| "c_lovers" # The Lovers: Enhances 1 selected card into a Wild Card
+---| "c_chariot" # The Chariot: Enhances 1 selected card into a Steel Card
+---| "c_justice" # Justice: Enhances 1 selected card into a Glass Card
+---| "c_hermit" # The Hermit: Doubles money (Max of $20)
+---| "c_wheel_of_fortune" # The Wheel of Fortune: 1 in 4 chance to add Foil, Holographic, or Polychrome edition to a random Joker
+---| "c_strength" # Strength: Increases rank of up to 2 selected cards by 1
+---| "c_hanged_man" # The Hanged Man: Destroys up to 2 selected cards
+---| "c_death" # Death: Select 2 cards, convert the left card into the right card (Drag to rearrange)
+---| "c_temperance" # Temperance: Gives the total sell value of all current Jokers (Max of $50)
+---| "c_devil" # The Devil: Enhances 1 selected card into a Gold Card
+---| "c_tower" # The Tower: Enhances 1 selected card into a Stone Card
+---| "c_star" # The Star: Converts up to 3 selected cards to Diamonds
+---| "c_moon" # The Moon: Converts up to 3 selected cards to Clubs
+---| "c_sun" # The Sun: Converts up to 3 selected cards to Hearts
+---| "c_judgement" # Judgement: Creates a random Joker card (Must have room)
+---| "c_world" # The World: Converts up to 3 selected cards to Spades
+
+---@alias Card.Key.Consumable.Planet
+---| "c_mercury" # Mercury: Increases Pair hand value by +1 Mult and +15 Chips
+---| "c_venus" # Venus: Increases Three of a Kind hand value by +2 Mult and +20 Chips
+---| "c_earth" # Earth: Increases Full House hand value by +2 Mult and +25 Chips
+---| "c_mars" # Mars: Increases Four of a Kind hand value by +3 Mult and +30 Chips
+---| "c_jupiter" # Jupiter: Increases Flush hand value by +2 Mult and +15 Chips
+---| "c_saturn" # Saturn: Increases Straight hand value by +3 Mult and +30 Chips
+---| "c_uranus" # Uranus: Increases Two Pair hand value by +1 Mult and +20 Chips
+---| "c_neptune" # Neptune: Increases Straight Flush hand value by +4 Mult and +40 Chips
+---| "c_pluto" # Pluto: Increases High Card hand value by +1 Mult and +10 Chips
+---| "c_planet_x" # Planet X: Increases Five of a Kind hand value by +3 Mult and +35 Chips
+---| "c_ceres" # Ceres: Increases Flush House hand value by +4 Mult and +40 Chips
+---| "c_eris" # Eris: Increases Flush Five hand value by +3 Mult and +50 Chips
+
+---@alias Card.Key.Consumable.Spectral
+---| "c_familiar" # Familiar: Destroy 1 random card in your hand, add 3 random Enhanced face cards to your hand
+---| "c_grim" # Grim: Destroy 1 random card in your hand, add 2 random Enhanced Aces to your hand
+---| "c_incantation" # Incantation: Destroy 1 random card in your hand, add 4 random Enhanced numbered cards to your hand
+---| "c_talisman" # Talisman: Add a Gold Seal to 1 selected card in your hand
+---| "c_aura" # Aura: Add Foil, Holographic, or Polychrome effect to 1 selected card in hand
+---| "c_wraith" # Wraith: Creates a random Rare Joker, sets money to $0
+---| "c_sigil" # Sigil: Converts all cards in hand to a single random suit
+---| "c_ouija" # Ouija: Converts all cards in hand to a single random rank (-1 hand size)
+---| "c_ectoplasm" # Ectoplasm: Add Negative to a random Joker, -1 hand size
+---| "c_immolate" # Immolate: Destroys 5 random cards in hand, gain $20
+---| "c_ankh" # Ankh: Create a copy of a random Joker, destroy all other Jokers (Removes Negative from copy)
+---| "c_deja_vu" # Deja Vu: Add a Red Seal to 1 selected card in your hand
+---| "c_hex" # Hex: Add Polychrome to a random Joker, destroy all other Jokers
+---| "c_trance" # Trance: Add a Blue Seal to 1 selected card in your hand
+---| "c_medium" # Medium: Add a Purple Seal to 1 selected card in your hand
+---| "c_cryptid" # Cryptid: Create 2 copies of 1 selected card in your hand
+---| "c_soul" # The Soul: Creates a Legendary Joker (Must have room)
+---| "c_black_hole" # Black Hole: Upgrade every poker hand by 1 level
+
+---@alias Card.Key.Joker
+---| "j_joker" # +4 Mult
+---| "j_greedy_joker" # Played cards with Diamond suit give +3 Mult when scored
+---| "j_lusty_joker" # Played cards with Heart suit give +3 Mult when scored
+---| "j_wrathful_joker" # Played cards with Spade suit give +3 Mult when scored
+---| "j_gluttenous_joker" # Played cards with Club suit give +3 Mult when scored
+---| "j_jolly" # +8 Mult if played hand contains a Pair
+---| "j_zany" # +12 Mult if played hand contains a Three of a Kind
+---| "j_mad" # +10 Mult if played hand contains a Two Pair
+---| "j_crazy" # +12 Mult if played hand contains a Straight
+---| "j_droll" # +10 Mult if played hand contains a Flush
+---| "j_sly" # +50 Chips if played hand contains a Pair
+---| "j_wily" # +100 Chips if played hand contains a Three of a Kind
+---| "j_clever" # +80 Chips if played hand contains a Two Pair
+---| "j_devious" # +100 Chips if played hand contains a Straight
+---| "j_crafty" # +80 Chips if played hand contains a Flush
+---| "j_half" # +20 Mult if played hand contains 3 or fewer cards
+---| "j_stencil" # X1 Mult for each empty Joker slot. Joker Stencil included (Currently X1 )
+---| "j_four_fingers" # All Flushes and Straights can be made with 4 cards
+---| "j_mime" # Retrigger all card held in hand abilities
+---| "j_credit_card" # Go up to -$20 in debt
+---| "j_ceremonial" # When Blind is selected, destroy Joker to the right and permanently add double its sell value to this Mult (Currently +0 Mult )
+---| "j_banner" # +30 Chips for each remaining discard
+---| "j_mystic_summit" # +15 Mult when 0 discards remaining
+---| "j_marble" # Adds one Stone card to the deck when Blind is selected
+---| "j_loyalty_card" # X4 Mult every 6 hands played 5 remaining
+---| "j_8_ball" # 1 in 4 chance for each played 8 to create a Tarot card when scored (Must have room)
+---| "j_misprint" # +0-23 Mult
+---| "j_dusk" # Retrigger all played cards in final hand of the round
+---| "j_raised_fist" # Adds double the rank of lowest ranked card held in hand to Mult
+---| "j_chaos" # 1 free Reroll per shop
+---| "j_fibonacci" # Each played Ace , 2 , 3 , 5 , or 8 gives +8 Mult when scored
+---| "j_steel_joker" # Gives X0.2 Mult for each Steel Card in your full deck (Currently X1 Mult )
+---| "j_scary_face" # Played face cards give +30 Chips when scored
+---| "j_abstract" # +3 Mult for each Joker card (Currently +0 Mult )
+---| "j_delayed_grat" # Earn $2 per discard if no discards are used by end of the round
+---| "j_hack" # Retrigger each played 2 , 3 , 4 , or 5
+---| "j_pareidolia" # All cards are considered face cards
+---| "j_gros_michel" # +15 Mult 1 in 6 chance this is destroyed at the end of round.
+---| "j_even_steven" # Played cards with even rank give +4 Mult when scored (10, 8, 6, 4, 2)
+---| "j_odd_todd" # Played cards with odd rank give +31 Chips when scored (A, 9, 7, 5, 3)
+---| "j_scholar" # Played Aces give +20 Chips and +4 Mult when scored
+---| "j_business" # Played face cards have a 1 in 2 chance to give $2 when scored
+---| "j_supernova" # Adds the number of times poker hand has been played this run to Mult
+---| "j_ride_the_bus" # This Joker gains +1 Mult per consecutive hand played without a scoring face card (Currently +0 Mult )
+---| "j_space" # 1 in 4 chance to upgrade level of played poker hand
+---| "j_egg" # Gains $3 of sell value at end of round
+---| "j_burglar" # When Blind is selected, gain +3 Hands and lose all discards
+---| "j_blackboard" # X3 Mult if all cards held in hand are Spades or Clubs
+---| "j_runner" # Gains +15 Chips if played hand contains a Straight (Currently +0 Chips )
+---| "j_ice_cream" # +100 Chips -5 Chips for every hand played
+---| "j_dna" # If first hand of round has only 1 card, add a permanent copy to deck and draw it to hand
+---| "j_splash" # Every played card counts in scoring
+---| "j_blue_joker" # +2 Chips for each remaining card in deck (Currently +104 Chips )
+---| "j_sixth_sense" # If first hand of round is a single 6 , destroy it and create a Spectral card (Must have room)
+---| "j_constellation" # This Joker gains X0.1 Mult every time a Planet card is used (Currently X1 Mult )
+---| "j_hiker" # Every played card permanently gains +5 Chips when scored
+---| "j_faceless" # Earn $5 if 3 or more face cards are discarded at the same time
+---| "j_green_joker" # +1 Mult per hand played -1 Mult per discard (Currently +0 Mult )
+---| "j_superposition" # Create a Tarot card if poker hand contains an Ace and a Straight (Must have room)
+---| "j_todo_list" # Earn $4 if poker hand is a [Poker Hand] , poker hand changes at end of round
+---| "j_cavendish" # X3 Mult 1 in 1000 chance this card is destroyed at the end of round
+---| "j_card_sharp" # X3 Mult if played poker hand has already been played this round
+---| "j_red_card" # This Joker gains +3 Mult when any Booster Pack is skipped (Currently +0 Mult )
+---| "j_madness" # When Small Blind or Big Blind is selected, gain X0.5 Mult and destroy a random Joker (Currently X1 Mult )
+---| "j_square" # This Joker gains +4 Chips if played hand has exactly 4 cards (Currently 0 Chips )
+---| "j_seance" # If poker hand is a Straight Flush , create a random Spectral card (Must have room)
+---| "j_riff_raff" # When Blind is selected, create 2 Common Jokers (Must have room)
+---| "j_vampire" # This Joker gains X0.1 Mult per scoring Enhanced card played, removes card Enhancement (Currently X1 Mult )
+---| "j_shortcut" # Allows Straights to be made with gaps of 1 rank (ex: 10 8 6 5 3 )
+---| "j_hologram" # This Joker gains X0.25 Mult every time a playing card is added to your deck (Currently X1 Mult )
+---| "j_vagabond" # Create a Tarot card if hand is played with $4 or less
+---| "j_baron" # Each King held in hand gives X1.5 Mult
+---| "j_cloud_9" # Earn $1 for each 9 in your full deck at end of round (Currently $4 )
+---| "j_rocket" # Earn $1 at end of round. Payout increases by $2 when Boss Blind is defeated
+---| "j_obelisk" # This Joker gains X0.2 Mult per consecutive hand played without playing your most played poker hand (Currently X1 Mult )
+---| "j_midas_mask" # All played face cards become Gold cards when scored
+---| "j_luchador" # Sell this card to disable the current Boss Blind
+---| "j_photograph" # First played face card gives X2 Mult when scored
+---| "j_gift" # Add $1 of sell value to every Joker and Consumable card at end of round
+---| "j_turtle_bean" # +5 hand size, reduces by 1 each round
+---| "j_erosion" # +4 Mult for each card below [the deck's starting size] in your full deck (Currently +0 Mult )
+---| "j_reserved_parking" # Each face card held in hand has a 1 in 2 chance to give $1
+---| "j_mail" # Earn $5 for each discarded [rank] , rank changes every round
+---| "j_to_the_moon" # Earn an extra $1 of interest for every $5 you have at end of round
+---| "j_hallucination" # 1 in 2 chance to create a Tarot card when any Booster Pack is opened (Must have room)
+---| "j_fortune_teller" # +1 Mult per Tarot card used this run (Currently +0 )
+---| "j_juggler" # +1 hand size
+---| "j_drunkard" # +1 discard each round
+---| "j_stone" # Gives +25 Chips for each Stone Card in your full deck (Currently +0 Chips )
+---| "j_golden" # Earn $4 at end of round
+---| "j_lucky_cat" # This Joker gains X0.25 Mult every time a Lucky card successfully triggers (Currently X1 Mult )
+---| "j_baseball" # Uncommon Jokers each give X1.5 Mult
+---| "j_bull" # +2 Chips for each $1 you have (Currently +0 Chips )
+---| "j_diet_cola" # Sell this card to create a free Double Tag
+---| "j_trading" # If first discard of round has only 1 card, destroy it and earn $3
+---| "j_flash" # This Joker gains +2 Mult per reroll in the shop (Currently +0 Mult )
+---| "j_popcorn" # +20 Mult -4 Mult per round played
+---| "j_trousers" # This Joker gains +2 Mult if played hand contains a Two Pair (Currently +0 Mult )
+---| "j_ancient" # Each played card with [suit] gives X1.5 Mult when scored, suit changes at end of round
+---| "j_ramen" # X2 Mult , loses X0.01 Mult per card discarded
+---| "j_walkie_talkie" # Each played 10 or 4 gives +10 Chips and +4 Mult when scored
+---| "j_selzer" # Retrigger all cards played for the next 10 hands
+---| "j_castle" # This Joker gains +3 Chips per discarded [suit] card, suit changes every round (Currently +0 Chips )
+---| "j_smiley" # Played face cards give +5 Mult when scored
+---| "j_campfire" # This Joker gains X0.25 Mult for each card sold , resets when Boss Blind is defeated (Currently X1 Mult )
+---| "j_ticket" # Played Gold cards earn $4 when scored
+---| "j_mr_bones" # Prevents Death if chips scored are at least 25% of required chips self destructs
+---| "j_acrobat" # X3 Mult on final hand of round
+---| "j_sock_and_buskin" # Retrigger all played face cards
+---| "j_swashbuckler" # Adds the sell value of all other owned Jokers to Mult (Currently +1 Mult )
+---| "j_troubadour" # +2 hand size, -1 hand each round
+---| "j_certificate" # When round begins, add a random playing card with a random seal to your hand
+---| "j_smeared" # Hearts and Diamonds count as the same suit, Spades and Clubs count as the same suit
+---| "j_throwback" # X0.25 Mult for each Blind skipped this run (Currently X1 Mult )
+---| "j_hanging_chad" # Retrigger first played card used in scoring 2 additional times
+---| "j_rough_gem" # Played cards with Diamond suit earn $1 when scored
+---| "j_bloodstone" # 1 in 2 chance for played cards with Heart suit to give X1.5 Mult when scored
+---| "j_arrowhead" # Played cards with Spade suit give +50 Chips when scored
+---| "j_onyx_agate" # Played cards with Club suit give +7 Mult when scored
+---| "j_glass" # This Joker gains X0.75 Mult for every Glass Card that is destroyed (Currently X1 Mult )
+---| "j_ring_master" # Joker , Tarot , Planet , and Spectral cards may appear multiple times
+---| "j_flower_pot" # X3 Mult if poker hand contains a Diamond card, Club card, Heart card, and Spade card
+---| "j_blueprint" # Copies ability of Joker to the right
+---| "j_wee" # This Joker gains +8 Chips when each played 2 is scored (Currently +0 Chips )
+---| "j_merry_andy" # +3 discards each round, -1 hand size
+---| "j_oops" # Doubles all listed probabilities (ex: 1 in 3 -> 2 in 3 )
+---| "j_idol" # Each played [rank] of [suit] gives X2 Mult when scored Card changes every round
+---| "j_seeing_double" # X2 Mult if played hand has a scoring Club card and a scoring card of any other suit
+---| "j_matador" # Earn $8 if played hand triggers the Boss Blind ability
+---| "j_hit_the_road" # This Joker gains X0.5 Mult for every Jack discarded this round (Currently X1 Mult )
+---| "j_duo" # X2 Mult if played hand contains a Pair
+---| "j_trio" # X3 Mult if played hand contains a Three of a Kind
+---| "j_family" # X4 Mult if played hand contains a Four of a Kind
+---| "j_order" # X3 Mult if played hand contains a Straight
+---| "j_tribe" # X2 Mult if played hand contains a Flush
+---| "j_stuntman" # +250 Chips , -2 hand size
+---| "j_invisible" # After 2 rounds, sell this card to Duplicate a random Joker (Currently 0 /2) (Removes Negative from copy)
+---| "j_brainstorm" # Copies the ability of leftmost Joker
+---| "j_satellite" # Earn $1 at end of round per unique Planet card used this run
+---| "j_shoot_the_moon" # Each Queen held in hand gives +13 Mult
+---| "j_drivers_license" # X3 Mult if you have at least 16 Enhanced cards in your full deck (Currently 0 )
+---| "j_cartomancer" # Create a Tarot card when Blind is selected (Must have room)
+---| "j_astronomer" # All Planet cards and Celestial Packs in the shop are free
+---| "j_burnt" # Upgrade the level of the first discarded poker hand each round
+---| "j_bootstraps" # +2 Mult for every $5 you have (Currently +0 Mult )
+---| "j_caino" # This Joker gains X1 Mult when a face card is destroyed (Currently X1 Mult )
+---| "j_triboulet" # Played Kings and Queens each give X2 Mult when scored
+---| "j_yorick" # This Joker gains X1 Mult every 23 [23] cards discarded (Currently X1 Mult )
+---| "j_chicot" # Disables effect of every Boss Blind
+---| "j_perkeo" # Creates a Negative copy of 1 random consumable card in your possession at the end of the shop
+
+---@alias Card.Key.Voucher
+---| "v_overstock_norm" # Overstock: +1 card slot available in shop (to 3 slots)
+---| "v_clearance_sale" # Clearance Sale: All cards and packs in shop are 25% off
+---| "v_hone" # Hone: Foil, Holographic, and Polychrome cards appear 2X more often
+---| "v_reroll_surplus" # Reroll Surplus: Rerolls cost $2 less
+---| "v_crystal_ball" # Crystal Ball: +1 consumable slot
+---| "v_telescope" # Telescope: Celestial Packs always contain the Planet card for your most played poker hand
+---| "v_grabber" # Grabber: Permanently gain +1 hand per round
+---| "v_wasteful" # Wasteful: Permanently gain +1 discard each round
+---| "v_tarot_merchant" # Tarot Merchant: Tarot cards appear 2X more frequently in the shop
+---| "v_planet_merchant" # Planet Merchant: Planet cards appear 2X more frequently in the shop
+---| "v_seed_money" # Seed Money: Raise the cap on interest earned in each round to $10
+---| "v_blank" # Blank: Does nothing?
+---| "v_magic_trick" # Magic Trick: Playing cards can be purchased from the shop
+---| "v_hieroglyph" # Hieroglyph: -1 Ante, -1 hand each round
+---| "v_directors_cut" # Director's Cut: Reroll Boss Blind 1 time per Ante, $10 per roll
+---| "v_paint_brush" # Paint Brush: +1 hand size
+---| "v_overstock_plus" # Overstock Plus: +1 card slot available in shop (to 4 slots)
+---| "v_liquidation" # Liquidation: All cards and packs in shop are 50% off
+---| "v_glow_up" # Glow Up: Foil, Holographic, and Polychrome cards appear 4X more often
+---| "v_reroll_glut" # Reroll Glut: Rerolls cost an additional $2 less
+---| "v_omen_globe" # Omen Globe: Spectral cards may appear in any of the Arcana Packs
+---| "v_observatory" # Observatory: Planet cards in your consumable area give X1.5 Mult for their specified poker hand
+---| "v_nacho_tong" # Nacho Tong: Permanently gain an additional +1 hand per round
+---| "v_recyclomancy" # Recyclomancy: Permanently gain an additional +1 discard each round
+---| "v_tarot_tycoon" # Tarot Tycoon: Tarot cards appear 4X more frequently in the shop
+---| "v_planet_tycoon" # Planet Tycoon: Planet cards appear 4X more frequently in the shop
+---| "v_money_tree" # Money Tree: Raise the cap on interest earned in each round to $20
+---| "v_antimatter" # Antimatter: +1 Joker slot
+---| "v_illusion" # Illusion: Playing cards in shop may have an Enhancement, Edition, and/or a Seal
+---| "v_petroglyph" # Petroglyph: -1 Ante again, -1 discard each round
+---| "v_retcon" # Retcon: Reroll Boss Blind unlimited times, $10 per roll
+---| "v_palette" # Palette: +1 hand size again
+
+---@alias Card.Key.PlayingCard
+---| "H_2" | "H_3" | "H_4" | "H_5" | "H_6" | "H_7" | "H_8" | "H_9" | "H_T" | "H_J" | "H_Q" | "H_K" | "H_A"
+---| "D_2" | "D_3" | "D_4" | "D_5" | "D_6" | "D_7" | "D_8" | "D_9" | "D_T" | "D_J" | "D_Q" | "D_K" | "D_A"
+---| "C_2" | "C_3" | "C_4" | "C_5" | "C_6" | "C_7" | "C_8" | "C_9" | "C_T" | "C_J" | "C_Q" | "C_K" | "C_A"
+---| "S_2" | "S_3" | "S_4" | "S_5" | "S_6" | "S_7" | "S_8" | "S_9" | "S_T" | "S_J" | "S_Q" | "S_K" | "S_A"
+
+---@alias Card.Key.Consumable
+---| Card.Key.Consumable.Tarot
+---| Card.Key.Consumable.Planet
+---| Card.Key.Consumable.Spectral
+
+---@alias Card.Key
+---| Card.Key.Consumable
+---| Card.Key.Joker
+---| Card.Key.Voucher
+---| Card.Key.PlayingCard
+
+---@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)
+
+---@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)
+
+---@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
+
+---@alias Blind.Type
+---| "SMALL" # No special effects - can be skipped to receive a Tag
+---| "BIG" # No special effects - can be skipped to receive a Tag
+---| "BOSS" # Various effect depending on the boss type
+
+---@alias Blind.Status
+---| "SELECT" # Selectable blind
+---| "CURRENT" # Current blind selected
+---| "UPCOMING" # Future blind
+---| "DEFEATED" # Previously defeated blind
+---| "SKIPPED" # Previously skipped blind
diff --git a/src/lua/utils/errors.lua b/src/lua/utils/errors.lua
new file mode 100644
index 0000000..dbe6707
--- /dev/null
+++ b/src/lua/utils/errors.lua
@@ -0,0 +1,22 @@
+--[[
+ Error definitions for BalatroBot API.
+ Type aliases defined in types.lua.
+]]
+
+---@type ErrorNames
+BB_ERROR_NAMES = {
+ INTERNAL_ERROR = "INTERNAL_ERROR",
+ BAD_REQUEST = "BAD_REQUEST",
+ INVALID_STATE = "INVALID_STATE",
+ NOT_ALLOWED = "NOT_ALLOWED",
+}
+
+---@type ErrorCodes
+BB_ERROR_CODES = {
+ INTERNAL_ERROR = -32000,
+ BAD_REQUEST = -32001,
+ INVALID_STATE = -32002,
+ NOT_ALLOWED = -32003,
+}
+
+return BB_ERROR_NAMES, BB_ERROR_CODES
diff --git a/src/lua/utils/gamestate.lua b/src/lua/utils/gamestate.lua
new file mode 100644
index 0000000..8d4e3a7
--- /dev/null
+++ b/src/lua/utils/gamestate.lua
@@ -0,0 +1,785 @@
+---Simplified game state extraction utilities
+---This module provides a clean, simplified interface for extracting game state
+---according to the new gamestate specification
+---@module 'gamestate'
+local gamestate = {}
+
+-- ==========================================================================
+-- State Name Mapping
+-- ==========================================================================
+
+---Converts numeric state ID to string state name
+---@param state_num number The numeric state value from G.STATE
+---@return string state_name The string name of the state (e.g., "SELECTING_HAND")
+local function get_state_name(state_num)
+ if not G or not G.STATES then
+ return "UNKNOWN"
+ end
+
+ for name, value in pairs(G.STATES) do
+ if value == state_num then
+ return name
+ end
+ end
+
+ 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 (from old utils)
+-- ==========================================================================
+
+---Gets the description text for a card by reading from its UI elements
+---@param card table The card object
+---@return string description The description text from UI
+local function get_card_ui_description(card)
+ -- Generate the UI structure (same as hover tooltip)
+ card:hover()
+ card:stop_hover()
+ local ui_table = card.ability_UIBox_table
+ if not ui_table then
+ return ""
+ end
+
+ -- Extract all text nodes from the UI tree
+ local texts = {}
+
+ -- The UI table has main/info/type sections
+ if ui_table.main then
+ for _, line in ipairs(ui_table.main) do
+ local line_texts = {}
+ for _, section in ipairs(line) do
+ if section.config and section.config.text then
+ -- normal text and colored text
+ line_texts[#line_texts + 1] = section.config.text
+ elseif section.nodes then
+ for _, node in ipairs(section.nodes) do
+ if node.config and node.config.text then
+ -- hightlighted text
+ line_texts[#line_texts + 1] = node.config.text
+ end
+ end
+ end
+ end
+ texts[#texts + 1] = table.concat(line_texts, "")
+ end
+ end
+
+ -- Join text lines with spaces (in the game these are separated by newlines)
+ return table.concat(texts, " ")
+end
+
+-- ==========================================================================
+-- Card Value Converters
+-- ==========================================================================
+
+---Converts Balatro suit name to enum format
+---@param suit_name string The suit name from card.config.card.suit
+---@return Card.Value.Suit? suit_enum The single-letter suit enum ("H", "D", "C", "S")
+local function convert_suit_to_enum(suit_name)
+ if suit_name == "Hearts" then
+ return "H"
+ elseif suit_name == "Diamonds" then
+ return "D"
+ elseif suit_name == "Clubs" then
+ return "C"
+ elseif suit_name == "Spades" then
+ return "S"
+ end
+ return nil
+end
+
+---Converts Balatro rank value to enum format
+---@param rank_value string The rank value from card.config.card.value
+---@return Card.Value.Rank? rank_enum The single-character rank enum
+local function convert_rank_to_enum(rank_value)
+ -- Numbers 2-9 stay the same
+ if
+ rank_value == "2"
+ or rank_value == "3"
+ or rank_value == "4"
+ or rank_value == "5"
+ or rank_value == "6"
+ or rank_value == "7"
+ or rank_value == "8"
+ or rank_value == "9"
+ then
+ return rank_value
+ elseif rank_value == "10" then
+ return "T"
+ elseif rank_value == "Jack" then
+ return "J"
+ elseif rank_value == "Queen" then
+ return "Q"
+ elseif rank_value == "King" then
+ return "K"
+ elseif rank_value == "Ace" then
+ return "A"
+ end
+ return nil
+end
+
+-- ==========================================================================
+-- Card Component Extractors
+-- ==========================================================================
+
+---Extracts modifier information from a card
+---@param card table The card object
+---@return Card.Modifier modifier The Card.Modifier object
+local function extract_card_modifier(card)
+ local modifier = {}
+
+ -- Seal (direct property)
+ if card.seal then
+ modifier.seal = string.upper(card.seal)
+ end
+
+ -- Edition (table with type/key)
+ if card.edition and card.edition.type then
+ modifier.edition = string.upper(card.edition.type)
+ 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", ""))
+ end
+
+ -- Eternal (boolean from ability)
+ if card.ability and card.ability.eternal then
+ modifier.eternal = true
+ end
+
+ -- Perishable (from perish_tally - only include if > 0)
+ if card.ability and card.ability.perish_tally and card.ability.perish_tally > 0 then
+ modifier.perishable = card.ability.perish_tally
+ end
+
+ -- Rental (boolean from ability)
+ if card.ability and card.ability.rental then
+ modifier.rental = true
+ end
+
+ return modifier
+end
+
+---Extracts value information from a card
+---@param card table The card object
+---@return Card.Value value The Card.Value object
+local function extract_card_value(card)
+ local value = {}
+
+ -- Suit and rank (for playing cards)
+ if card.config and card.config.card then
+ if card.config.card.suit then
+ value.suit = convert_suit_to_enum(card.config.card.suit)
+ end
+ if card.config.card.value then
+ value.rank = convert_rank_to_enum(card.config.card.value)
+ end
+ end
+
+ -- Effect description (for all cards)
+ value.effect = get_card_ui_description(card)
+
+ return value
+end
+
+---Extracts state information from a card
+---@param card table The card object
+---@return Card.State state The Card.State object
+local function extract_card_state(card)
+ local state = {}
+
+ -- Debuff
+ if card.debuff then
+ state.debuff = true
+ end
+
+ -- Hidden (facing == "back")
+ if card.facing and card.facing == "back" then
+ state.hidden = true
+ end
+
+ -- Highlighted
+ if card.highlighted then
+ state.highlight = true
+ end
+
+ return state
+end
+
+---Extracts cost information from a card
+---@param card table The card object
+---@return Card.Cost cost The Card.Cost object
+local function extract_card_cost(card)
+ return {
+ sell = card.sell_cost or 0,
+ buy = card.cost or 0,
+ }
+end
+
+-- ==========================================================================
+-- Card Extractor
+-- ==========================================================================
+
+---Extracts a complete Card object from a game card
+---@param card table The game card object
+---@return Card card The Card object
+local function extract_card(card)
+ -- Determine set
+ local set = "DEFAULT"
+ if card.ability and card.ability.set then
+ local ability_set = card.ability.set
+ if ability_set == "Joker" then
+ set = "JOKER"
+ elseif ability_set == "Tarot" then
+ set = "TAROT"
+ elseif ability_set == "Planet" then
+ set = "PLANET"
+ elseif ability_set == "Spectral" then
+ set = "SPECTRAL"
+ elseif ability_set == "Voucher" then
+ set = "VOUCHER"
+ elseif ability_set == "Booster" then
+ set = "BOOSTER"
+ elseif ability_set == "Edition" then
+ set = "EDITION"
+ elseif card.ability.effect and card.ability.effect ~= "Base" then
+ set = "ENHANCED"
+ end
+ end
+
+ -- Extract key (prefer card_key for playing cards, fallback to center_key)
+ local key = ""
+ if card.config then
+ if card.config.card_key then
+ key = card.config.card_key
+ elseif card.config.center_key then
+ key = card.config.center_key
+ end
+ end
+
+ return {
+ id = card.sort_id or 0,
+ key = key,
+ set = set,
+ label = card.label or "",
+ value = extract_card_value(card),
+ modifier = extract_card_modifier(card),
+ state = extract_card_state(card),
+ cost = extract_card_cost(card),
+ }
+end
+
+-- ==========================================================================
+-- Area Extractor
+-- ==========================================================================
+
+---Extracts an Area object from a game area (like G.jokers, G.hand, etc.)
+---@param area table The game area object
+---@return Area? area_data The Area object
+local function extract_area(area)
+ if not area then
+ return nil
+ end
+
+ local cards = {}
+ if area.cards then
+ for i, card in pairs(area.cards) do
+ cards[i] = extract_card(card)
+ end
+ end
+
+ local area_data = {
+ count = (area.config and area.config.card_count) or 0,
+ limit = (area.config and area.config.card_limit) or 0,
+ cards = cards,
+ }
+
+ -- Add highlighted_limit if available (for hand area)
+ if area.config and area.config.highlighted_limit then
+ area_data.highlighted_limit = area.config.highlighted_limit
+ end
+
+ return area_data
+end
+
+-- ==========================================================================
+-- Poker Hands Extractor
+-- ==========================================================================
+
+---Extracts poker hands information
+---@param hands table The G.GAME.hands table
+---@return table hands_data The hands information
+local function extract_hand_info(hands)
+ if not hands then
+ return {}
+ end
+
+ local hands_data = {}
+ for name, hand in pairs(hands) do
+ hands_data[name] = {
+ order = hand.order or 0,
+ level = hand.level or 1,
+ chips = hand.chips or 0,
+ mult = hand.mult or 0,
+ played = hand.played or 0,
+ played_this_round = hand.played_this_round or 0,
+ example = hand.example or {},
+ }
+ end
+
+ return hands_data
+end
+
+-- ==========================================================================
+-- Round Info Extractor
+-- ==========================================================================
+
+---Extracts round state information
+---@return Round round The Round object
+local function extract_round_info()
+ if not G or not G.GAME or not G.GAME.current_round then
+ return {}
+ end
+
+ local round = {}
+
+ if G.GAME.current_round.hands_left then
+ round.hands_left = G.GAME.current_round.hands_left
+ end
+
+ if G.GAME.current_round.hands_played then
+ round.hands_played = G.GAME.current_round.hands_played
+ end
+
+ if G.GAME.current_round.discards_left then
+ round.discards_left = G.GAME.current_round.discards_left
+ end
+
+ if G.GAME.current_round.discards_used then
+ round.discards_used = G.GAME.current_round.discards_used
+ end
+
+ if G.GAME.current_round.reroll_cost then
+ round.reroll_cost = G.GAME.current_round.reroll_cost
+ end
+
+ -- Chips is stored in G.GAME not G.GAME.current_round
+ if G.GAME.chips then
+ round.chips = G.GAME.chips
+ end
+
+ return round
+end
+
+-- ==========================================================================
+-- Blind Information
+-- ==========================================================================
+
+---Gets blind effect description from localization data
+---@param blind_config table The blind configuration from G.P_BLINDS
+---@return string effect The effect description
+local function get_blind_effect_from_ui(blind_config)
+ if not blind_config or not blind_config.key then
+ return ""
+ end
+
+ -- Small and Big blinds have no effect
+ if blind_config.key == "bl_small" or blind_config.key == "bl_big" then
+ return ""
+ end
+
+ -- 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
+ return ""
+ end
+
+ local loc_data = G.localization.descriptions ---@diagnostic disable-line: undefined-global
+ if not loc_data or not loc_data.Blind or not loc_data.Blind[blind_config.key] then
+ return ""
+ end
+
+ local blind_data = loc_data.Blind[blind_config.key]
+ if not blind_data.text or type(blind_data.text) ~= "table" then
+ return ""
+ end
+
+ -- Concatenate all description lines
+ local effect_parts = {}
+ for _, line in ipairs(blind_data.text) do
+ if line and line ~= "" then
+ effect_parts[#effect_parts + 1] = line
+ end
+ end
+
+ return table.concat(effect_parts, " ")
+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}
+local function get_tag_info(tag_key)
+ local result = { name = "", effect = "" }
+
+ if not tag_key or not G.P_TAGS or not G.P_TAGS[tag_key] then
+ return result
+ end
+
+ if not localize then ---@diagnostic disable-line: undefined-global
+ return result
+ end
+
+ local tag_data = G.P_TAGS[tag_key]
+ result.name = tag_data.name or ""
+
+ -- Build loc_vars based on tag name (same logic as Tag:get_uibox_table in tag.lua:545-561)
+ local loc_vars = {}
+ local name = tag_data.name
+ if name == "Investment Tag" then
+ loc_vars = { tag_data.config and tag_data.config.dollars or 0 }
+ elseif name == "Handy Tag" then
+ local dollars_per_hand = tag_data.config and tag_data.config.dollars_per_hand or 0
+ local hands_played = (G.GAME and G.GAME.hands_played) or 0
+ loc_vars = { dollars_per_hand, dollars_per_hand * hands_played }
+ elseif name == "Garbage Tag" then
+ local dollars_per_discard = tag_data.config and tag_data.config.dollars_per_discard or 0
+ local unused_discards = (G.GAME and G.GAME.unused_discards) or 0
+ loc_vars = { dollars_per_discard, dollars_per_discard * unused_discards }
+ elseif name == "Juggle Tag" then
+ loc_vars = { tag_data.config and tag_data.config.h_size or 0 }
+ elseif name == "Top-up Tag" then
+ loc_vars = { tag_data.config and tag_data.config.spawn_jokers or 0 }
+ elseif name == "Skip Tag" then
+ local skip_bonus = tag_data.config and tag_data.config.skip_bonus or 0
+ local skips = (G.GAME and G.GAME.skips) or 0
+ loc_vars = { skip_bonus, skip_bonus * (skips + 1) }
+ elseif name == "Orbital Tag" then
+ local orbital_hand = "Poker Hand" -- Default placeholder
+ local levels = tag_data.config and tag_data.config.levels or 0
+ loc_vars = { orbital_hand, levels }
+ elseif name == "Economy Tag" then
+ loc_vars = { tag_data.config and tag_data.config.max or 0 }
+ 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
+ if text_lines and type(text_lines) == "table" then
+ result.effect = table.concat(text_lines, " ")
+ end
+
+ return result
+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")
+local function convert_status_to_enum(status)
+ if status == "Defeated" then
+ return "DEFEATED"
+ elseif status == "Skipped" then
+ return "SKIPPED"
+ elseif status == "Current" then
+ return "CURRENT"
+ elseif status == "Select" then
+ return "SELECT"
+ elseif status == "Upcoming" then
+ return "UPCOMING"
+ else
+ return "UPCOMING" -- Default fallback
+ end
+end
+
+---Gets comprehensive blind information for the current ante
+---@return table blinds Information about small, big, and boss blinds
+function gamestate.get_blinds_info()
+ -- Initialize with default structure matching the Blind type
+ local blinds = {
+ small = {
+ type = "SMALL",
+ status = "UPCOMING",
+ name = "",
+ effect = "",
+ score = 0,
+ tag_name = "",
+ tag_effect = "",
+ },
+ big = {
+ type = "BIG",
+ status = "UPCOMING",
+ name = "",
+ effect = "",
+ score = 0,
+ tag_name = "",
+ tag_effect = "",
+ },
+ boss = {
+ type = "BOSS",
+ status = "UPCOMING",
+ name = "",
+ effect = "",
+ score = 0,
+ tag_name = "",
+ tag_effect = "",
+ },
+ }
+
+ if not G.GAME or not G.GAME.round_resets then
+ return blinds
+ end
+
+ -- 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
+
+ -- Apply ante scaling with null check
+ local ante_scaling = (G.GAME.starting_params and G.GAME.starting_params.ante_scaling) or 1
+
+ -- Get blind choices
+ local blind_choices = G.GAME.round_resets.blind_choices or {}
+ local blind_states = G.GAME.round_resets.blind_states or {}
+
+ -- ====================
+ -- Small Blind
+ -- ====================
+ local small_choice = blind_choices.Small or "bl_small"
+ 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"
+ blinds.small.score = math.floor(base_amount * (small_blind.mult or 1) * ante_scaling)
+ blinds.small.effect = get_blind_effect_from_ui(small_blind)
+
+ -- Set status
+ if blind_states.Small then
+ blinds.small.status = convert_status_to_enum(blind_states.Small)
+ end
+
+ -- Get tag information
+ 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
+ end
+ end
+
+ -- ====================
+ -- Big Blind
+ -- ====================
+ local big_choice = blind_choices.Big or "bl_big"
+ 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"
+ blinds.big.score = math.floor(base_amount * (big_blind.mult or 1.5) * ante_scaling)
+ blinds.big.effect = get_blind_effect_from_ui(big_blind)
+
+ -- Set status
+ if blind_states.Big then
+ blinds.big.status = convert_status_to_enum(blind_states.Big)
+ end
+
+ -- Get tag information
+ 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
+ end
+ end
+
+ -- ====================
+ -- Boss Blind
+ -- ====================
+ local boss_choice = blind_choices.Boss
+ 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"
+ blinds.boss.score = math.floor(base_amount * (boss_blind.mult or 2) * ante_scaling)
+ blinds.boss.effect = get_blind_effect_from_ui(boss_blind)
+
+ -- Set status
+ if blind_states.Boss then
+ blinds.boss.status = convert_status_to_enum(blind_states.Boss)
+ end
+ else
+ -- Fallback if boss blind not yet determined
+ blinds.boss.name = "Boss Blind"
+ blinds.boss.score = math.floor(base_amount * 2 * ante_scaling)
+ end
+
+ -- Boss blind has no tags (tag_name and tag_effect remain empty strings)
+
+ return blinds
+end
+
+-- ==========================================================================
+-- Main Gamestate Extractor
+-- ==========================================================================
+
+---Extracts the simplified game state according to the new specification
+---@return GameState gamestate The complete simplified game state
+function gamestate.get_gamestate()
+ if not G then
+ return {
+ state = "UNKNOWN",
+ round_num = 0,
+ ante_num = 0,
+ money = 0,
+ }
+ end
+
+ local state_data = {
+ state = get_state_name(G.STATE),
+ }
+
+ -- Basic game info
+ if G.GAME then
+ state_data.round_num = G.GAME.round or 0
+ state_data.ante_num = (G.GAME.round_resets and G.GAME.round_resets.ante) or 0
+ state_data.money = G.GAME.dollars or 0
+ state_data.won = G.GAME.won
+
+ -- 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)
+ end
+
+ -- Stake (optional)
+ if G.GAME.stake then
+ state_data.stake = get_stake_name(G.GAME.stake)
+ end
+
+ -- Seed (optional)
+ if G.GAME.pseudorandom and G.GAME.pseudorandom.seed then
+ state_data.seed = G.GAME.pseudorandom.seed
+ end
+
+ -- Used vouchers (table)
+ 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
+ end
+ state_data.used_vouchers = used_vouchers
+ end
+
+ -- Poker hands
+ if G.GAME.hands then
+ state_data.hands = extract_hand_info(G.GAME.hands)
+ end
+
+ -- Round info
+ state_data.round = extract_round_info()
+
+ -- Blinds info
+ state_data.blinds = gamestate.get_blinds_info()
+ end
+
+ -- Always available areas
+ state_data.jokers = extract_area(G.jokers)
+ state_data.consumables = extract_area(G.consumeables) -- Note: typo in game code
+
+ -- Phase-specific areas
+ -- Hand (available during playing phase)
+ if G.hand then
+ state_data.hand = extract_area(G.hand)
+ end
+
+ -- Shop areas (available during shop phase)
+ if G.shop_jokers then
+ state_data.shop = extract_area(G.shop_jokers)
+ end
+
+ if G.shop_vouchers then
+ state_data.vouchers = extract_area(G.shop_vouchers)
+ end
+
+ if G.shop_booster then
+ state_data.packs = extract_area(G.shop_booster)
+ end
+
+ -- Pack cards area (available during pack opening phases)
+ if G.pack_cards and not G.pack_cards.REMOVED then
+ state_data.pack = extract_area(G.pack_cards)
+ end
+
+ return state_data
+end
+
+-- ==========================================================================
+-- GAME_OVER Callback Support
+-- ==========================================================================
+
+-- Callback set by endpoints that need immediate GAME_OVER notification
+-- This is necessary because when G.STATE becomes GAME_OVER, the game pauses
+-- (G.SETTINGS.paused = true) which stops event processing, preventing
+-- normal event-based detection from working.
+gamestate.on_game_over = nil
+
+---Check and trigger GAME_OVER callback if state is GAME_OVER
+---Called from love.update before game logic runs
+function gamestate.check_game_over()
+ if gamestate.on_game_over and G.STATE == G.STATES.GAME_OVER then
+ gamestate.on_game_over(gamestate.get_gamestate())
+ gamestate.on_game_over = nil
+ end
+end
+
+return gamestate
diff --git a/src/lua/utils/openrpc.json b/src/lua/utils/openrpc.json
new file mode 100644
index 0000000..49f1159
--- /dev/null
+++ b/src/lua/utils/openrpc.json
@@ -0,0 +1,2909 @@
+{
+ "openrpc": "1.3.2",
+ "info": {
+ "title": "BalatroBot API",
+ "description": "JSON-RPC 2.0 API for Balatro bot development. This API allows external clients to control the Balatro game, query game state, and execute actions through an HTTP server.",
+ "version": "1.0.0",
+ "license": {
+ "name": "MIT"
+ }
+ },
+ "servers": [
+ {
+ "name": "Local",
+ "url": "http://127.0.0.1:12346",
+ "description": "Local HTTP server for Balatro game communication"
+ }
+ ],
+ "methods": [
+ {
+ "name": "rpc.discover",
+ "summary": "Returns the OpenRPC schema for this service",
+ "description": "Service discovery method that returns the OpenRPC specification document describing this JSON-RPC API.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/state"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "OpenRPC Schema",
+ "schema": {
+ "$ref": "https://raw.githubusercontent.com/open-rpc/meta-schema/master/schema.json"
+ }
+ },
+ "errors": []
+ },
+ {
+ "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).",
+ "tags": [
+ {
+ "$ref": "#/components/tags/cards"
+ }
+ ],
+ "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)",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/CardKey"
+ }
+ },
+ {
+ "name": "seal",
+ "description": "Seal type for playing cards only",
+ "required": false,
+ "schema": {
+ "$ref": "#/components/schemas/Seal"
+ }
+ },
+ {
+ "name": "edition",
+ "description": "Edition type. NEGATIVE only valid for consumables; jokers and playing cards accept all editions. Not valid for vouchers.",
+ "required": false,
+ "schema": {
+ "$ref": "#/components/schemas/Edition"
+ }
+ },
+ {
+ "name": "enhancement",
+ "description": "Enhancement type for playing cards only",
+ "required": false,
+ "schema": {
+ "$ref": "#/components/schemas/Enhancement"
+ }
+ },
+ {
+ "name": "eternal",
+ "description": "If true, card cannot be sold or destroyed (jokers only)",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "perishable",
+ "description": "Number of rounds before card perishes (must be >= 1, jokers only)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "rental",
+ "description": "If true, card costs $1 per round (jokers only)",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after card is added",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/InvalidState"
+ }
+ ]
+ },
+ {
+ "name": "buy",
+ "summary": "Buy a card from the shop",
+ "description": "Buy a card, voucher, or pack from the shop. Must provide exactly one of: card, voucher, or pack.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/shop"
+ }
+ ],
+ "params": [
+ {
+ "name": "card",
+ "description": "0-based index of card to buy from shop",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "voucher",
+ "description": "0-based index of voucher to buy",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "pack",
+ "description": "0-based index of pack to buy",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after purchase",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ },
+ {
+ "name": "cash_out",
+ "summary": "Cash out and collect round rewards",
+ "description": "Cash out and collect round rewards, transitioning to the shop phase.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/shop"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after transitioning to shop",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InvalidState"
+ }
+ ]
+ },
+ {
+ "name": "discard",
+ "summary": "Discard cards from the hand",
+ "description": "Discard specified cards from the hand. Card indices are 0-based.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/cards"
+ }
+ ],
+ "params": [
+ {
+ "name": "cards",
+ "description": "0-based indices of cards to discard (non-empty array)",
+ "required": true,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "minItems": 1
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after discard and hand redraw",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ }
+ ]
+ },
+ {
+ "name": "gamestate",
+ "summary": "Get current game state",
+ "description": "Get the complete current game state. Works in any game state.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/state"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete current game state",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": []
+ },
+ {
+ "name": "health",
+ "summary": "Health check endpoint",
+ "description": "Health check endpoint for connection testing. Always succeeds and works in any game state.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/state"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "health",
+ "description": "Health check response",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ok"
+ ]
+ }
+ },
+ "required": [
+ "status"
+ ]
+ }
+ },
+ "errors": []
+ },
+ {
+ "name": "load",
+ "summary": "Load a saved run state from a file",
+ "description": "Load a previously saved run state from a file. The file must be a valid Balatro save file.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/game-control"
+ }
+ ],
+ "params": [
+ {
+ "name": "path",
+ "description": "File path to the save file",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "result": {
+ "name": "result",
+ "description": "Load operation result",
+ "schema": {
+ "$ref": "#/components/schemas/PathResult"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InternalError"
+ }
+ ]
+ },
+ {
+ "name": "menu",
+ "summary": "Return to the main menu",
+ "description": "Return to the main menu from any game state.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/game-control"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state (state will be MENU)",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": []
+ },
+ {
+ "name": "next_round",
+ "summary": "Leave the shop and advance to blind selection",
+ "description": "Leave the shop and advance to the blind selection phase.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/shop"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state (state will be BLIND_SELECT)",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InvalidState"
+ }
+ ]
+ },
+ {
+ "name": "play",
+ "summary": "Play cards from the hand",
+ "description": "Play specified cards from the hand. Card indices are 0-based.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/cards"
+ }
+ ],
+ "params": [
+ {
+ "name": "cards",
+ "description": "0-based indices of cards to play (non-empty array)",
+ "required": true,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "minItems": 1
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after playing cards. State may be ROUND_EVAL, SELECTING_HAND, or GAME_OVER.",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ }
+ ]
+ },
+ {
+ "name": "rearrange",
+ "summary": "Rearrange cards in hand, jokers, or consumables",
+ "description": "Rearrange cards by providing a new ordering. Must provide exactly one of: hand, jokers, or consumables. The array must be a valid permutation of all current indices.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/cards"
+ }
+ ],
+ "params": [
+ {
+ "name": "hand",
+ "description": "0-based indices representing new order of cards in hand",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
+ {
+ "name": "jokers",
+ "description": "0-based indices representing new order of jokers",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
+ {
+ "name": "consumables",
+ "description": "0-based indices representing new order of consumables",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after rearrangement",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ },
+ {
+ "name": "reroll",
+ "summary": "Reroll shop items",
+ "description": "Reroll to update the cards in the shop area. Costs money.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/shop"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after reroll",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ },
+ {
+ "name": "save",
+ "summary": "Save the current run state to a file",
+ "description": "Save the current run state to a file. Only works during an active run.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/game-control"
+ }
+ ],
+ "params": [
+ {
+ "name": "path",
+ "description": "File path for the save file",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "result": {
+ "name": "result",
+ "description": "Save operation result",
+ "schema": {
+ "$ref": "#/components/schemas/PathResult"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/InternalError"
+ }
+ ]
+ },
+ {
+ "name": "screenshot",
+ "summary": "Take a screenshot of the current game state",
+ "description": "Take a screenshot of the current game state and save it as PNG format.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/utility"
+ }
+ ],
+ "params": [
+ {
+ "name": "path",
+ "description": "File path for the screenshot file (PNG format)",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "result": {
+ "name": "result",
+ "description": "Screenshot operation result",
+ "schema": {
+ "$ref": "#/components/schemas/PathResult"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InternalError"
+ }
+ ]
+ },
+ {
+ "name": "select",
+ "summary": "Select the current blind",
+ "description": "Select the current blind to begin the round.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/blind"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state (state will be SELECTING_HAND)",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InvalidState"
+ }
+ ]
+ },
+ {
+ "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.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/shop"
+ }
+ ],
+ "params": [
+ {
+ "name": "joker",
+ "description": "0-based index of joker to sell",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "consumable",
+ "description": "0-based index of consumable to sell",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after sale",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ },
+ {
+ "name": "set",
+ "summary": "Set an in-game value",
+ "description": "Set one or more in-game values. Must provide at least one field. Only works during an active run.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/utility"
+ }
+ ],
+ "params": [
+ {
+ "name": "money",
+ "description": "New money amount (must be >= 0)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "chips",
+ "description": "New chips amount (must be >= 0)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "ante",
+ "description": "New ante number (must be >= 0)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "round",
+ "description": "New round number (must be >= 0)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "hands",
+ "description": "New number of hands left (must be >= 0)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "discards",
+ "description": "New number of discards left (must be >= 0)",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "shop",
+ "description": "If true, re-stock shop with new items (only in SHOP state)",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after setting values",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ },
+ {
+ "name": "skip",
+ "summary": "Skip the current blind",
+ "description": "Skip the current blind (Small or Big only, cannot skip Boss blind).",
+ "tags": [
+ {
+ "$ref": "#/components/tags/blind"
+ }
+ ],
+ "params": [],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after skipping",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ },
+ {
+ "name": "start",
+ "summary": "Start a new game run",
+ "description": "Start a new game run with specified deck and stake.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/game-control"
+ }
+ ],
+ "params": [
+ {
+ "name": "deck",
+ "description": "Deck to use for the run",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Deck"
+ }
+ },
+ {
+ "name": "stake",
+ "description": "Stake level for the run",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Stake"
+ }
+ },
+ {
+ "name": "seed",
+ "description": "Optional seed for the run",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state (state will be BLIND_SELECT)",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/InternalError"
+ }
+ ]
+ },
+ {
+ "name": "use",
+ "summary": "Use a consumable card",
+ "description": "Use a consumable card with optional target cards. Some consumables require card selection.",
+ "tags": [
+ {
+ "$ref": "#/components/tags/cards"
+ }
+ ],
+ "params": [
+ {
+ "name": "consumable",
+ "description": "0-based index of consumable to use",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "cards",
+ "description": "0-based indices of cards to target (required for some consumables)",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ ],
+ "result": {
+ "name": "gamestate",
+ "description": "Complete game state after using consumable",
+ "schema": {
+ "$ref": "#/components/schemas/GameState"
+ }
+ },
+ "errors": [
+ {
+ "$ref": "#/components/errors/BadRequest"
+ },
+ {
+ "$ref": "#/components/errors/InvalidState"
+ },
+ {
+ "$ref": "#/components/errors/NotAllowed"
+ }
+ ]
+ }
+ ],
+ "components": {
+ "tags": {
+ "state": {
+ "name": "state",
+ "description": "Game state query endpoints"
+ },
+ "game-control": {
+ "name": "game-control",
+ "description": "Game lifecycle control (start, menu, load, save)"
+ },
+ "blind": {
+ "name": "blind",
+ "description": "Blind selection and skipping"
+ },
+ "shop": {
+ "name": "shop",
+ "description": "Shop interactions (buy, sell, reroll, cash_out)"
+ },
+ "cards": {
+ "name": "cards",
+ "description": "Card manipulation (play, discard, rearrange, use, add)"
+ },
+ "utility": {
+ "name": "utility",
+ "description": "Utility endpoints (screenshot, set)"
+ }
+ },
+ "schemas": {
+ "GameState": {
+ "type": "object",
+ "description": "Complete game state representation",
+ "properties": {
+ "state": {
+ "$ref": "#/components/schemas/State"
+ },
+ "round_num": {
+ "type": "integer",
+ "description": "Current round number"
+ },
+ "ante_num": {
+ "type": "integer",
+ "description": "Current ante number"
+ },
+ "money": {
+ "type": "integer",
+ "description": "Current money amount"
+ },
+ "deck": {
+ "$ref": "#/components/schemas/Deck",
+ "description": "Current selected deck"
+ },
+ "stake": {
+ "$ref": "#/components/schemas/Stake",
+ "description": "Current selected stake"
+ },
+ "seed": {
+ "type": "string",
+ "description": "Seed used for the run"
+ },
+ "won": {
+ "type": "boolean",
+ "description": "Whether the game has been won"
+ },
+ "used_vouchers": {
+ "type": "object",
+ "description": "Vouchers used (name -> description)",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "hands": {
+ "type": "object",
+ "description": "Poker hands information",
+ "additionalProperties": {
+ "$ref": "#/components/schemas/Hand"
+ }
+ },
+ "round": {
+ "$ref": "#/components/schemas/Round"
+ },
+ "blinds": {
+ "type": "object",
+ "description": "Blind information",
+ "properties": {
+ "small": {
+ "$ref": "#/components/schemas/Blind"
+ },
+ "big": {
+ "$ref": "#/components/schemas/Blind"
+ },
+ "boss": {
+ "$ref": "#/components/schemas/Blind"
+ }
+ }
+ },
+ "jokers": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Jokers area"
+ },
+ "consumables": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Consumables area"
+ },
+ "hand": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Hand area (available during playing phase)"
+ },
+ "pack": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Currently open pack (available during pack opening phase)"
+ },
+ "shop": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Shop area (available during shop phase)"
+ },
+ "vouchers": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Vouchers area (available during shop phase)"
+ },
+ "packs": {
+ "$ref": "#/components/schemas/Area",
+ "description": "Booster packs area (available during shop phase)"
+ }
+ },
+ "required": [
+ "state",
+ "round_num",
+ "ante_num",
+ "money"
+ ]
+ },
+ "Hand": {
+ "type": "object",
+ "description": "Poker hand information",
+ "properties": {
+ "order": {
+ "type": "integer",
+ "description": "The importance/ordering of the hand"
+ },
+ "level": {
+ "type": "integer",
+ "description": "Level of the hand in the current run"
+ },
+ "chips": {
+ "type": "integer",
+ "description": "Current chip value for this hand"
+ },
+ "mult": {
+ "type": "integer",
+ "description": "Current multiplier value for this hand"
+ },
+ "played": {
+ "type": "integer",
+ "description": "Total number of times this hand has been played"
+ },
+ "played_this_round": {
+ "type": "integer",
+ "description": "Number of times this hand has been played this round"
+ },
+ "example": {
+ "type": "array",
+ "description": "Example cards showing what makes this hand",
+ "items": {
+ "type": "array"
+ }
+ }
+ },
+ "required": [
+ "order",
+ "level",
+ "chips",
+ "mult",
+ "played",
+ "played_this_round"
+ ]
+ },
+ "Round": {
+ "type": "object",
+ "description": "Current round state",
+ "properties": {
+ "hands_left": {
+ "type": "integer",
+ "description": "Number of hands remaining in this round"
+ },
+ "hands_played": {
+ "type": "integer",
+ "description": "Number of hands played in this round"
+ },
+ "discards_left": {
+ "type": "integer",
+ "description": "Number of discards remaining in this round"
+ },
+ "discards_used": {
+ "type": "integer",
+ "description": "Number of discards used in this round"
+ },
+ "reroll_cost": {
+ "type": "integer",
+ "description": "Current cost to reroll the shop"
+ },
+ "chips": {
+ "type": "integer",
+ "description": "Current chips scored in this round"
+ }
+ }
+ },
+ "Blind": {
+ "type": "object",
+ "description": "Blind information",
+ "properties": {
+ "type": {
+ "$ref": "#/components/schemas/BlindType"
+ },
+ "status": {
+ "$ref": "#/components/schemas/BlindStatus"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the blind (e.g., 'Small', 'Big' or the Boss name)"
+ },
+ "effect": {
+ "type": "string",
+ "description": "Description of the blind's effect"
+ },
+ "score": {
+ "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)"
+ }
+ },
+ "required": [
+ "type",
+ "status",
+ "name",
+ "effect",
+ "score"
+ ]
+ },
+ "Area": {
+ "type": "object",
+ "description": "Card area (jokers, consumables, hand, shop, etc.)",
+ "properties": {
+ "count": {
+ "type": "integer",
+ "description": "Current number of cards in this area"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of cards allowed in this area"
+ },
+ "highlighted_limit": {
+ "type": "integer",
+ "description": "Maximum number of cards that can be highlighted (hand area only)"
+ },
+ "cards": {
+ "type": "array",
+ "description": "Array of cards in this area",
+ "items": {
+ "$ref": "#/components/schemas/Card"
+ }
+ }
+ },
+ "required": [
+ "count",
+ "limit",
+ "cards"
+ ]
+ },
+ "Card": {
+ "type": "object",
+ "description": "Card representation",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Unique identifier for the card (sort_id)"
+ },
+ "key": {
+ "type": "string",
+ "description": "Specific card key (e.g., 'c_fool', 'j_brainstorm', 'v_overstock')"
+ },
+ "set": {
+ "$ref": "#/components/schemas/CardSet"
+ },
+ "label": {
+ "type": "string",
+ "description": "Display label/name of the card"
+ },
+ "value": {
+ "$ref": "#/components/schemas/CardValue"
+ },
+ "modifier": {
+ "$ref": "#/components/schemas/CardModifier"
+ },
+ "state": {
+ "$ref": "#/components/schemas/CardState"
+ },
+ "cost": {
+ "$ref": "#/components/schemas/CardCost"
+ }
+ },
+ "required": [
+ "id",
+ "key",
+ "set",
+ "label",
+ "value",
+ "modifier",
+ "state",
+ "cost"
+ ]
+ },
+ "CardValue": {
+ "type": "object",
+ "description": "Value information for the card",
+ "properties": {
+ "suit": {
+ "$ref": "#/components/schemas/Suit",
+ "description": "Suit (only for playing cards)"
+ },
+ "rank": {
+ "$ref": "#/components/schemas/Rank",
+ "description": "Rank (only for playing cards)"
+ },
+ "effect": {
+ "type": "string",
+ "description": "Description of the card's effect (from UI)"
+ }
+ },
+ "required": [
+ "effect"
+ ]
+ },
+ "CardModifier": {
+ "type": "object",
+ "description": "Modifier information (seals, editions, enhancements)",
+ "properties": {
+ "seal": {
+ "$ref": "#/components/schemas/Seal",
+ "description": "Seal type (playing cards)"
+ },
+ "edition": {
+ "$ref": "#/components/schemas/Edition",
+ "description": "Edition type (jokers, playing cards, and NEGATIVE consumables)"
+ },
+ "enhancement": {
+ "$ref": "#/components/schemas/Enhancement",
+ "description": "Enhancement type (playing cards)"
+ },
+ "eternal": {
+ "type": "boolean",
+ "description": "If true, card cannot be sold or destroyed (jokers only)"
+ },
+ "perishable": {
+ "type": "integer",
+ "description": "Number of rounds remaining (only if > 0, jokers only)"
+ },
+ "rental": {
+ "type": "boolean",
+ "description": "If true, card costs money at end of round (jokers only)"
+ }
+ }
+ },
+ "CardState": {
+ "type": "object",
+ "description": "Current state information",
+ "properties": {
+ "debuff": {
+ "type": "boolean",
+ "description": "If true, card is debuffed and won't score"
+ },
+ "hidden": {
+ "type": "boolean",
+ "description": "If true, card is face down"
+ },
+ "highlight": {
+ "type": "boolean",
+ "description": "If true, card is currently highlighted"
+ }
+ }
+ },
+ "CardCost": {
+ "type": "object",
+ "description": "Cost information",
+ "properties": {
+ "sell": {
+ "type": "integer",
+ "description": "Sell value of the card"
+ },
+ "buy": {
+ "type": "integer",
+ "description": "Buy price of the card (if in shop)"
+ }
+ },
+ "required": [
+ "sell",
+ "buy"
+ ]
+ },
+ "PathResult": {
+ "type": "object",
+ "description": "Result for file operations (save, load, screenshot)",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "description": "Whether the operation was successful"
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to the file"
+ }
+ },
+ "required": [
+ "success",
+ "path"
+ ]
+ },
+ "State": {
+ "description": "Game state enumeration",
+ "oneOf": [
+ {
+ "const": "MENU",
+ "description": "Main menu of the game"
+ },
+ {
+ "const": "BLIND_SELECT",
+ "description": "Blind selection phase"
+ },
+ {
+ "const": "SELECTING_HAND",
+ "description": "Selecting cards to play or discard"
+ },
+ {
+ "const": "HAND_PLAYED",
+ "description": "During hand playing animation"
+ },
+ {
+ "const": "DRAW_TO_HAND",
+ "description": "During hand drawing animation"
+ },
+ {
+ "const": "NEW_ROUND",
+ "description": "Round is won and new round begins"
+ },
+ {
+ "const": "ROUND_EVAL",
+ "description": "Round end, inside the cash out phase"
+ },
+ {
+ "const": "SHOP",
+ "description": "Inside the shop"
+ },
+ {
+ "const": "PLAY_TAROT",
+ "description": "Playing a tarot card"
+ },
+ {
+ "const": "TAROT_PACK",
+ "description": "Opening a tarot pack"
+ },
+ {
+ "const": "PLANET_PACK",
+ "description": "Opening a planet pack"
+ },
+ {
+ "const": "SPECTRAL_PACK",
+ "description": "Opening a spectral pack"
+ },
+ {
+ "const": "STANDARD_PACK",
+ "description": "Opening a standard pack"
+ },
+ {
+ "const": "BUFFOON_PACK",
+ "description": "Opening a buffoon pack"
+ },
+ {
+ "const": "GAME_OVER",
+ "description": "Game is over"
+ }
+ ]
+ },
+ "Deck": {
+ "description": "Deck type",
+ "oneOf": [
+ {
+ "const": "RED",
+ "description": "+1 discard every round"
+ },
+ {
+ "const": "BLUE",
+ "description": "+1 hand every round"
+ },
+ {
+ "const": "YELLOW",
+ "description": "Start with extra $10"
+ },
+ {
+ "const": "GREEN",
+ "description": "$2 per remaining Hand, $1 per remaining Discard, no interest"
+ },
+ {
+ "const": "BLACK",
+ "description": "+1 Joker slot, -1 hand every round"
+ },
+ {
+ "const": "MAGIC",
+ "description": "Start with Crystal Ball voucher and 2 copies of The Fool"
+ },
+ {
+ "const": "NEBULA",
+ "description": "Start with Telescope voucher, -1 consumable slot"
+ },
+ {
+ "const": "GHOST",
+ "description": "Spectral cards may appear in shop, start with Hex card"
+ },
+ {
+ "const": "ABANDONED",
+ "description": "Start with no Face Cards in deck"
+ },
+ {
+ "const": "CHECKERED",
+ "description": "Start with 26 Spades and 26 Hearts in deck"
+ },
+ {
+ "const": "ZODIAC",
+ "description": "Start with Tarot Merchant, Planet Merchant, and Overstock"
+ },
+ {
+ "const": "PAINTED",
+ "description": "+2 hand size, -1 Joker slot"
+ },
+ {
+ "const": "ANAGLYPH",
+ "description": "Gain Double Tag after each Boss Blind"
+ },
+ {
+ "const": "PLASMA",
+ "description": "Balanced Chips and Mult, 2X base Blind size"
+ },
+ {
+ "const": "ERRATIC",
+ "description": "All Ranks and Suits in deck are randomized"
+ }
+ ]
+ },
+ "Stake": {
+ "description": "Stake level",
+ "oneOf": [
+ {
+ "const": "WHITE",
+ "description": "Base Difficulty"
+ },
+ {
+ "const": "RED",
+ "description": "Small Blind gives no reward money"
+ },
+ {
+ "const": "GREEN",
+ "description": "Required scores scale faster for each Ante"
+ },
+ {
+ "const": "BLACK",
+ "description": "Shop can have Eternal Jokers"
+ },
+ {
+ "const": "BLUE",
+ "description": "-1 Discard"
+ },
+ {
+ "const": "PURPLE",
+ "description": "Required score scales faster for each Ante"
+ },
+ {
+ "const": "ORANGE",
+ "description": "Shop can have Perishable Jokers"
+ },
+ {
+ "const": "GOLD",
+ "description": "Shop can have Rental Jokers"
+ }
+ ]
+ },
+ "Suit": {
+ "description": "Card suit",
+ "oneOf": [
+ {
+ "const": "H",
+ "description": "Hearts"
+ },
+ {
+ "const": "D",
+ "description": "Diamonds"
+ },
+ {
+ "const": "C",
+ "description": "Clubs"
+ },
+ {
+ "const": "S",
+ "description": "Spades"
+ }
+ ]
+ },
+ "Rank": {
+ "description": "Card rank",
+ "oneOf": [
+ {
+ "const": "2",
+ "description": "Two"
+ },
+ {
+ "const": "3",
+ "description": "Three"
+ },
+ {
+ "const": "4",
+ "description": "Four"
+ },
+ {
+ "const": "5",
+ "description": "Five"
+ },
+ {
+ "const": "6",
+ "description": "Six"
+ },
+ {
+ "const": "7",
+ "description": "Seven"
+ },
+ {
+ "const": "8",
+ "description": "Eight"
+ },
+ {
+ "const": "9",
+ "description": "Nine"
+ },
+ {
+ "const": "T",
+ "description": "Ten"
+ },
+ {
+ "const": "J",
+ "description": "Jack"
+ },
+ {
+ "const": "Q",
+ "description": "Queen"
+ },
+ {
+ "const": "K",
+ "description": "King"
+ },
+ {
+ "const": "A",
+ "description": "Ace"
+ }
+ ]
+ },
+ "Seal": {
+ "description": "Card seal type",
+ "oneOf": [
+ {
+ "const": "RED",
+ "description": "Retrigger this card 1 time"
+ },
+ {
+ "const": "BLUE",
+ "description": "Creates Planet card for final played poker hand if held in hand"
+ },
+ {
+ "const": "GOLD",
+ "description": "Earn $3 when this card is played and scores"
+ },
+ {
+ "const": "PURPLE",
+ "description": "Creates a Tarot card when discarded"
+ }
+ ]
+ },
+ "Edition": {
+ "description": "Card edition type",
+ "oneOf": [
+ {
+ "const": "FOIL",
+ "description": "+50 Chips when scored"
+ },
+ {
+ "const": "HOLO",
+ "description": "+10 Mult when scored"
+ },
+ {
+ "const": "POLYCHROME",
+ "description": "X1.5 Mult when scored"
+ },
+ {
+ "const": "NEGATIVE",
+ "description": "+1 Joker slot (Jokers) or +1 Consumable slot (Consumables)"
+ }
+ ]
+ },
+ "Enhancement": {
+ "description": "Card enhancement type",
+ "oneOf": [
+ {
+ "const": "BONUS",
+ "description": "+30 Chips when scored"
+ },
+ {
+ "const": "MULT",
+ "description": "+4 Mult when scored"
+ },
+ {
+ "const": "WILD",
+ "description": "Counts as every suit simultaneously"
+ },
+ {
+ "const": "GLASS",
+ "description": "X2 Mult when scored"
+ },
+ {
+ "const": "STEEL",
+ "description": "X1.5 Mult while held in hand"
+ },
+ {
+ "const": "STONE",
+ "description": "+50 Chips, no rank or suit"
+ },
+ {
+ "const": "GOLD",
+ "description": "$3 if held in hand at end of round"
+ },
+ {
+ "const": "LUCKY",
+ "description": "1 in 5 chance +20 Mult, 1 in 15 chance $20"
+ }
+ ]
+ },
+ "CardSet": {
+ "description": "Card set/type",
+ "oneOf": [
+ {
+ "const": "DEFAULT",
+ "description": "Default playing card"
+ },
+ {
+ "const": "ENHANCED",
+ "description": "Playing card with an enhancement"
+ },
+ {
+ "const": "JOKER",
+ "description": "Joker card"
+ },
+ {
+ "const": "TAROT",
+ "description": "Tarot consumable card"
+ },
+ {
+ "const": "PLANET",
+ "description": "Planet consumable card"
+ },
+ {
+ "const": "SPECTRAL",
+ "description": "Spectral consumable card"
+ },
+ {
+ "const": "VOUCHER",
+ "description": "Voucher card"
+ },
+ {
+ "const": "BOOSTER",
+ "description": "Booster pack"
+ }
+ ]
+ },
+ "BlindType": {
+ "description": "Blind type",
+ "oneOf": [
+ {
+ "const": "SMALL",
+ "description": "No special effects, can be skipped for a Tag"
+ },
+ {
+ "const": "BIG",
+ "description": "No special effects, can be skipped for a Tag"
+ },
+ {
+ "const": "BOSS",
+ "description": "Various effects depending on boss type, cannot be skipped"
+ }
+ ]
+ },
+ "BlindStatus": {
+ "description": "Blind status",
+ "oneOf": [
+ {
+ "const": "SELECT",
+ "description": "Selectable blind"
+ },
+ {
+ "const": "CURRENT",
+ "description": "Currently selected blind"
+ },
+ {
+ "const": "UPCOMING",
+ "description": "Future blind"
+ },
+ {
+ "const": "DEFEATED",
+ "description": "Previously defeated blind"
+ },
+ {
+ "const": "SKIPPED",
+ "description": "Previously skipped blind"
+ }
+ ]
+ },
+ "TarotKey": {
+ "description": "Tarot consumable card key",
+ "oneOf": [
+ {
+ "const": "c_fool",
+ "description": "The Fool: Creates the last Tarot or Planet card used during this run"
+ },
+ {
+ "const": "c_magician",
+ "description": "The Magician: Enhances 2 selected cards to Lucky Cards"
+ },
+ {
+ "const": "c_high_priestess",
+ "description": "The High Priestess: Creates up to 2 random Planet cards"
+ },
+ {
+ "const": "c_empress",
+ "description": "The Empress: Enhances 2 selected cards to Mult Cards"
+ },
+ {
+ "const": "c_emperor",
+ "description": "The Emperor: Creates up to 2 random Tarot cards"
+ },
+ {
+ "const": "c_heirophant",
+ "description": "The Hierophant: Enhances 2 selected cards to Bonus Cards"
+ },
+ {
+ "const": "c_lovers",
+ "description": "The Lovers: Enhances 1 selected card into a Wild Card"
+ },
+ {
+ "const": "c_chariot",
+ "description": "The Chariot: Enhances 1 selected card into a Steel Card"
+ },
+ {
+ "const": "c_justice",
+ "description": "Justice: Enhances 1 selected card into a Glass Card"
+ },
+ {
+ "const": "c_hermit",
+ "description": "The Hermit: Doubles money (Max of $20)"
+ },
+ {
+ "const": "c_wheel_of_fortune",
+ "description": "The Wheel of Fortune: 1 in 4 chance to add edition to a random Joker"
+ },
+ {
+ "const": "c_strength",
+ "description": "Strength: Increases rank of up to 2 selected cards by 1"
+ },
+ {
+ "const": "c_hanged_man",
+ "description": "The Hanged Man: Destroys up to 2 selected cards"
+ },
+ {
+ "const": "c_death",
+ "description": "Death: Select 2 cards, convert the left card into the right card"
+ },
+ {
+ "const": "c_temperance",
+ "description": "Temperance: Gives the total sell value of all current Jokers (Max $50)"
+ },
+ {
+ "const": "c_devil",
+ "description": "The Devil: Enhances 1 selected card into a Gold Card"
+ },
+ {
+ "const": "c_tower",
+ "description": "The Tower: Enhances 1 selected card into a Stone Card"
+ },
+ {
+ "const": "c_star",
+ "description": "The Star: Converts up to 3 selected cards to Diamonds"
+ },
+ {
+ "const": "c_moon",
+ "description": "The Moon: Converts up to 3 selected cards to Clubs"
+ },
+ {
+ "const": "c_sun",
+ "description": "The Sun: Converts up to 3 selected cards to Hearts"
+ },
+ {
+ "const": "c_judgement",
+ "description": "Judgement: Creates a random Joker card"
+ },
+ {
+ "const": "c_world",
+ "description": "The World: Converts up to 3 selected cards to Spades"
+ }
+ ]
+ },
+ "PlanetKey": {
+ "description": "Planet consumable card key",
+ "oneOf": [
+ {
+ "const": "c_mercury",
+ "description": "Mercury: Upgrades Pair (+1 Mult, +15 Chips)"
+ },
+ {
+ "const": "c_venus",
+ "description": "Venus: Upgrades Three of a Kind (+2 Mult, +20 Chips)"
+ },
+ {
+ "const": "c_earth",
+ "description": "Earth: Upgrades Full House (+2 Mult, +25 Chips)"
+ },
+ {
+ "const": "c_mars",
+ "description": "Mars: Upgrades Four of a Kind (+3 Mult, +30 Chips)"
+ },
+ {
+ "const": "c_jupiter",
+ "description": "Jupiter: Upgrades Flush (+2 Mult, +15 Chips)"
+ },
+ {
+ "const": "c_saturn",
+ "description": "Saturn: Upgrades Straight (+3 Mult, +30 Chips)"
+ },
+ {
+ "const": "c_uranus",
+ "description": "Uranus: Upgrades Two Pair (+1 Mult, +20 Chips)"
+ },
+ {
+ "const": "c_neptune",
+ "description": "Neptune: Upgrades Straight Flush (+4 Mult, +40 Chips)"
+ },
+ {
+ "const": "c_pluto",
+ "description": "Pluto: Upgrades High Card (+1 Mult, +10 Chips)"
+ },
+ {
+ "const": "c_planet_x",
+ "description": "Planet X: Upgrades Five of a Kind (+3 Mult, +35 Chips)"
+ },
+ {
+ "const": "c_ceres",
+ "description": "Ceres: Upgrades Flush House (+4 Mult, +40 Chips)"
+ },
+ {
+ "const": "c_eris",
+ "description": "Eris: Upgrades Flush Five (+3 Mult, +50 Chips)"
+ }
+ ]
+ },
+ "SpectralKey": {
+ "description": "Spectral consumable card key",
+ "oneOf": [
+ {
+ "const": "c_familiar",
+ "description": "Familiar: Destroy 1 random card, add 3 random Enhanced face cards"
+ },
+ {
+ "const": "c_grim",
+ "description": "Grim: Destroy 1 random card, add 2 random Enhanced Aces"
+ },
+ {
+ "const": "c_incantation",
+ "description": "Incantation: Destroy 1 random card, add 4 random Enhanced numbered cards"
+ },
+ {
+ "const": "c_talisman",
+ "description": "Talisman: Add a Gold Seal to 1 selected card"
+ },
+ {
+ "const": "c_aura",
+ "description": "Aura: Add Foil, Holographic, or Polychrome to 1 selected card"
+ },
+ {
+ "const": "c_wraith",
+ "description": "Wraith: Creates a random Rare Joker, sets money to $0"
+ },
+ {
+ "const": "c_sigil",
+ "description": "Sigil: Converts all cards in hand to a single random suit"
+ },
+ {
+ "const": "c_ouija",
+ "description": "Ouija: Converts all cards in hand to a single random rank, -1 hand size"
+ },
+ {
+ "const": "c_ectoplasm",
+ "description": "Ectoplasm: Add Negative to a random Joker, -1 hand size"
+ },
+ {
+ "const": "c_immolate",
+ "description": "Immolate: Destroys 5 random cards in hand, gain $20"
+ },
+ {
+ "const": "c_ankh",
+ "description": "Ankh: Create a copy of a random Joker, destroy all other Jokers"
+ },
+ {
+ "const": "c_deja_vu",
+ "description": "Deja Vu: Add a Red Seal to 1 selected card"
+ },
+ {
+ "const": "c_hex",
+ "description": "Hex: Add Polychrome to a random Joker, destroy all other Jokers"
+ },
+ {
+ "const": "c_trance",
+ "description": "Trance: Add a Blue Seal to 1 selected card"
+ },
+ {
+ "const": "c_medium",
+ "description": "Medium: Add a Purple Seal to 1 selected card"
+ },
+ {
+ "const": "c_cryptid",
+ "description": "Cryptid: Create 2 copies of 1 selected card"
+ },
+ {
+ "const": "c_soul",
+ "description": "The Soul: Creates a Legendary Joker"
+ },
+ {
+ "const": "c_black_hole",
+ "description": "Black Hole: Upgrade every poker hand by 1 level"
+ }
+ ]
+ },
+ "JokerKey": {
+ "description": "Joker card key",
+ "oneOf": [
+ {
+ "const": "j_joker",
+ "description": "+4 Mult"
+ },
+ {
+ "const": "j_greedy_joker",
+ "description": "Played Diamond cards give +3 Mult when scored"
+ },
+ {
+ "const": "j_lusty_joker",
+ "description": "Played Heart cards give +3 Mult when scored"
+ },
+ {
+ "const": "j_wrathful_joker",
+ "description": "Played Spade cards give +3 Mult when scored"
+ },
+ {
+ "const": "j_gluttenous_joker",
+ "description": "Played Club cards give +3 Mult when scored"
+ },
+ {
+ "const": "j_jolly",
+ "description": "+8 Mult if played hand contains a Pair"
+ },
+ {
+ "const": "j_zany",
+ "description": "+12 Mult if played hand contains a Three of a Kind"
+ },
+ {
+ "const": "j_mad",
+ "description": "+10 Mult if played hand contains a Two Pair"
+ },
+ {
+ "const": "j_crazy",
+ "description": "+12 Mult if played hand contains a Straight"
+ },
+ {
+ "const": "j_droll",
+ "description": "+10 Mult if played hand contains a Flush"
+ },
+ {
+ "const": "j_sly",
+ "description": "+50 Chips if played hand contains a Pair"
+ },
+ {
+ "const": "j_wily",
+ "description": "+100 Chips if played hand contains a Three of a Kind"
+ },
+ {
+ "const": "j_clever",
+ "description": "+80 Chips if played hand contains a Two Pair"
+ },
+ {
+ "const": "j_devious",
+ "description": "+100 Chips if played hand contains a Straight"
+ },
+ {
+ "const": "j_crafty",
+ "description": "+80 Chips if played hand contains a Flush"
+ },
+ {
+ "const": "j_half",
+ "description": "+20 Mult if played hand contains 3 or fewer cards"
+ },
+ {
+ "const": "j_stencil",
+ "description": "X1 Mult for each empty Joker slot"
+ },
+ {
+ "const": "j_four_fingers",
+ "description": "All Flushes and Straights can be made with 4 cards"
+ },
+ {
+ "const": "j_mime",
+ "description": "Retrigger all card held in hand abilities"
+ },
+ {
+ "const": "j_credit_card",
+ "description": "Go up to -$20 in debt"
+ },
+ {
+ "const": "j_ceremonial",
+ "description": "When Blind selected, destroy Joker to right, add double sell value to Mult"
+ },
+ {
+ "const": "j_banner",
+ "description": "+30 Chips for each remaining discard"
+ },
+ {
+ "const": "j_mystic_summit",
+ "description": "+15 Mult when 0 discards remaining"
+ },
+ {
+ "const": "j_marble",
+ "description": "Adds one Stone card to deck when Blind is selected"
+ },
+ {
+ "const": "j_loyalty_card",
+ "description": "X4 Mult every 6 hands played"
+ },
+ {
+ "const": "j_8_ball",
+ "description": "1 in 4 chance for each played 8 to create a Tarot card"
+ },
+ {
+ "const": "j_misprint",
+ "description": "+0-23 Mult"
+ },
+ {
+ "const": "j_dusk",
+ "description": "Retrigger all played cards in final hand of the round"
+ },
+ {
+ "const": "j_raised_fist",
+ "description": "Adds double the rank of lowest ranked card held in hand to Mult"
+ },
+ {
+ "const": "j_chaos",
+ "description": "1 free Reroll per shop"
+ },
+ {
+ "const": "j_fibonacci",
+ "description": "Each played Ace, 2, 3, 5, or 8 gives +8 Mult when scored"
+ },
+ {
+ "const": "j_steel_joker",
+ "description": "Gives X0.2 Mult for each Steel Card in your full deck"
+ },
+ {
+ "const": "j_scary_face",
+ "description": "Played face cards give +30 Chips when scored"
+ },
+ {
+ "const": "j_abstract",
+ "description": "+3 Mult for each Joker card"
+ },
+ {
+ "const": "j_delayed_grat",
+ "description": "Earn $2 per discard if no discards are used by end of round"
+ },
+ {
+ "const": "j_hack",
+ "description": "Retrigger each played 2, 3, 4, or 5"
+ },
+ {
+ "const": "j_pareidolia",
+ "description": "All cards are considered face cards"
+ },
+ {
+ "const": "j_gros_michel",
+ "description": "+15 Mult, 1 in 6 chance destroyed at end of round"
+ },
+ {
+ "const": "j_even_steven",
+ "description": "Played cards with even rank give +4 Mult when scored"
+ },
+ {
+ "const": "j_odd_todd",
+ "description": "Played cards with odd rank give +31 Chips when scored"
+ },
+ {
+ "const": "j_scholar",
+ "description": "Played Aces give +20 Chips and +4 Mult when scored"
+ },
+ {
+ "const": "j_business",
+ "description": "Played face cards have 1 in 2 chance to give $2 when scored"
+ },
+ {
+ "const": "j_supernova",
+ "description": "Adds times poker hand played this run to Mult"
+ },
+ {
+ "const": "j_ride_the_bus",
+ "description": "Gains +1 Mult per consecutive hand without scoring face card"
+ },
+ {
+ "const": "j_space",
+ "description": "1 in 4 chance to upgrade level of played poker hand"
+ },
+ {
+ "const": "j_egg",
+ "description": "Gains $3 of sell value at end of round"
+ },
+ {
+ "const": "j_burglar",
+ "description": "When Blind selected, gain +3 Hands and lose all discards"
+ },
+ {
+ "const": "j_blackboard",
+ "description": "X3 Mult if all cards held in hand are Spades or Clubs"
+ },
+ {
+ "const": "j_runner",
+ "description": "Gains +15 Chips if played hand contains a Straight"
+ },
+ {
+ "const": "j_ice_cream",
+ "description": "+100 Chips, -5 Chips for every hand played"
+ },
+ {
+ "const": "j_dna",
+ "description": "If first hand has only 1 card, add permanent copy to deck"
+ },
+ {
+ "const": "j_splash",
+ "description": "Every played card counts in scoring"
+ },
+ {
+ "const": "j_blue_joker",
+ "description": "+2 Chips for each remaining card in deck"
+ },
+ {
+ "const": "j_sixth_sense",
+ "description": "If first hand is a single 6, destroy it and create a Spectral card"
+ },
+ {
+ "const": "j_constellation",
+ "description": "Gains X0.1 Mult every time a Planet card is used"
+ },
+ {
+ "const": "j_hiker",
+ "description": "Every played card permanently gains +5 Chips when scored"
+ },
+ {
+ "const": "j_faceless",
+ "description": "Earn $5 if 3 or more face cards are discarded at once"
+ },
+ {
+ "const": "j_green_joker",
+ "description": "+1 Mult per hand played, -1 Mult per discard"
+ },
+ {
+ "const": "j_superposition",
+ "description": "Create a Tarot card if hand contains Ace and Straight"
+ },
+ {
+ "const": "j_todo_list",
+ "description": "Earn $4 if poker hand matches, hand changes at end of round"
+ },
+ {
+ "const": "j_cavendish",
+ "description": "X3 Mult, 1 in 1000 chance destroyed at end of round"
+ },
+ {
+ "const": "j_card_sharp",
+ "description": "X3 Mult if played poker hand already played this round"
+ },
+ {
+ "const": "j_red_card",
+ "description": "Gains +3 Mult when any Booster Pack is skipped"
+ },
+ {
+ "const": "j_madness",
+ "description": "When Small/Big Blind selected, gain X0.5 Mult, destroy random Joker"
+ },
+ {
+ "const": "j_square",
+ "description": "Gains +4 Chips if played hand has exactly 4 cards"
+ },
+ {
+ "const": "j_seance",
+ "description": "If hand is Straight Flush, create a random Spectral card"
+ },
+ {
+ "const": "j_riff_raff",
+ "description": "When Blind selected, create 2 Common Jokers"
+ },
+ {
+ "const": "j_vampire",
+ "description": "Gains X0.1 Mult per scoring Enhanced card, removes Enhancement"
+ },
+ {
+ "const": "j_shortcut",
+ "description": "Allows Straights to be made with gaps of 1 rank"
+ },
+ {
+ "const": "j_hologram",
+ "description": "Gains X0.25 Mult every time a playing card is added to deck"
+ },
+ {
+ "const": "j_vagabond",
+ "description": "Create a Tarot card if hand played with $4 or less"
+ },
+ {
+ "const": "j_baron",
+ "description": "Each King held in hand gives X1.5 Mult"
+ },
+ {
+ "const": "j_cloud_9",
+ "description": "Earn $1 for each 9 in your full deck at end of round"
+ },
+ {
+ "const": "j_rocket",
+ "description": "Earn $1 at end of round, +$2 when Boss Blind defeated"
+ },
+ {
+ "const": "j_obelisk",
+ "description": "Gains X0.2 Mult per hand without playing most played hand"
+ },
+ {
+ "const": "j_midas_mask",
+ "description": "All played face cards become Gold cards when scored"
+ },
+ {
+ "const": "j_luchador",
+ "description": "Sell this card to disable the current Boss Blind"
+ },
+ {
+ "const": "j_photograph",
+ "description": "First played face card gives X2 Mult when scored"
+ },
+ {
+ "const": "j_gift",
+ "description": "Add $1 sell value to every Joker and Consumable at end of round"
+ },
+ {
+ "const": "j_turtle_bean",
+ "description": "+5 hand size, reduces by 1 each round"
+ },
+ {
+ "const": "j_erosion",
+ "description": "+4 Mult for each card below deck's starting size"
+ },
+ {
+ "const": "j_reserved_parking",
+ "description": "Each face card held has 1 in 2 chance to give $1"
+ },
+ {
+ "const": "j_mail",
+ "description": "Earn $5 for each discarded card of specific rank"
+ },
+ {
+ "const": "j_to_the_moon",
+ "description": "Earn extra $1 interest for every $5 at end of round"
+ },
+ {
+ "const": "j_hallucination",
+ "description": "1 in 2 chance to create Tarot when Booster Pack opened"
+ },
+ {
+ "const": "j_fortune_teller",
+ "description": "+1 Mult per Tarot card used this run"
+ },
+ {
+ "const": "j_juggler",
+ "description": "+1 hand size"
+ },
+ {
+ "const": "j_drunkard",
+ "description": "+1 discard each round"
+ },
+ {
+ "const": "j_stone",
+ "description": "Gives +25 Chips for each Stone Card in your full deck"
+ },
+ {
+ "const": "j_golden",
+ "description": "Earn $4 at end of round"
+ },
+ {
+ "const": "j_lucky_cat",
+ "description": "Gains X0.25 Mult every time a Lucky card triggers"
+ },
+ {
+ "const": "j_baseball",
+ "description": "Uncommon Jokers each give X1.5 Mult"
+ },
+ {
+ "const": "j_bull",
+ "description": "+2 Chips for each $1 you have"
+ },
+ {
+ "const": "j_diet_cola",
+ "description": "Sell this card to create a free Double Tag"
+ },
+ {
+ "const": "j_trading",
+ "description": "If first discard has only 1 card, destroy it and earn $3"
+ },
+ {
+ "const": "j_flash",
+ "description": "Gains +2 Mult per reroll in the shop"
+ },
+ {
+ "const": "j_popcorn",
+ "description": "+20 Mult, -4 Mult per round played"
+ },
+ {
+ "const": "j_trousers",
+ "description": "Gains +2 Mult if played hand contains a Two Pair"
+ },
+ {
+ "const": "j_ancient",
+ "description": "Each played card with specific suit gives X1.5 Mult"
+ },
+ {
+ "const": "j_ramen",
+ "description": "X2 Mult, loses X0.01 Mult per card discarded"
+ },
+ {
+ "const": "j_walkie_talkie",
+ "description": "Each played 10 or 4 gives +10 Chips and +4 Mult"
+ },
+ {
+ "const": "j_selzer",
+ "description": "Retrigger all cards played for the next 10 hands"
+ },
+ {
+ "const": "j_castle",
+ "description": "Gains +3 Chips per discarded card of specific suit"
+ },
+ {
+ "const": "j_smiley",
+ "description": "Played face cards give +5 Mult when scored"
+ },
+ {
+ "const": "j_campfire",
+ "description": "Gains X0.25 Mult for each card sold, resets on Boss Blind"
+ },
+ {
+ "const": "j_ticket",
+ "description": "Played Gold cards earn $4 when scored"
+ },
+ {
+ "const": "j_mr_bones",
+ "description": "Prevents Death if chips >= 25% of required, self destructs"
+ },
+ {
+ "const": "j_acrobat",
+ "description": "X3 Mult on final hand of round"
+ },
+ {
+ "const": "j_sock_and_buskin",
+ "description": "Retrigger all played face cards"
+ },
+ {
+ "const": "j_swashbuckler",
+ "description": "Adds sell value of all other Jokers to Mult"
+ },
+ {
+ "const": "j_troubadour",
+ "description": "+2 hand size, -1 hand each round"
+ },
+ {
+ "const": "j_certificate",
+ "description": "When round begins, add random playing card with seal to hand"
+ },
+ {
+ "const": "j_smeared",
+ "description": "Hearts/Diamonds same suit, Spades/Clubs same suit"
+ },
+ {
+ "const": "j_throwback",
+ "description": "X0.25 Mult for each Blind skipped this run"
+ },
+ {
+ "const": "j_hanging_chad",
+ "description": "Retrigger first played card 2 additional times"
+ },
+ {
+ "const": "j_rough_gem",
+ "description": "Played Diamond cards earn $1 when scored"
+ },
+ {
+ "const": "j_bloodstone",
+ "description": "1 in 2 chance for Heart cards to give X1.5 Mult"
+ },
+ {
+ "const": "j_arrowhead",
+ "description": "Played Spade cards give +50 Chips when scored"
+ },
+ {
+ "const": "j_onyx_agate",
+ "description": "Played Club cards give +7 Mult when scored"
+ },
+ {
+ "const": "j_glass",
+ "description": "Gains X0.75 Mult for every Glass Card destroyed"
+ },
+ {
+ "const": "j_ring_master",
+ "description": "Joker, Tarot, Planet, Spectral cards may appear multiple times"
+ },
+ {
+ "const": "j_flower_pot",
+ "description": "X3 Mult if hand contains Diamond, Club, Heart, and Spade"
+ },
+ {
+ "const": "j_blueprint",
+ "description": "Copies ability of Joker to the right"
+ },
+ {
+ "const": "j_wee",
+ "description": "Gains +8 Chips when each played 2 is scored"
+ },
+ {
+ "const": "j_merry_andy",
+ "description": "+3 discards each round, -1 hand size"
+ },
+ {
+ "const": "j_oops",
+ "description": "Doubles all listed probabilities"
+ },
+ {
+ "const": "j_idol",
+ "description": "Each played card of specific rank and suit gives X2 Mult"
+ },
+ {
+ "const": "j_seeing_double",
+ "description": "X2 Mult if hand has scoring Club and card of other suit"
+ },
+ {
+ "const": "j_matador",
+ "description": "Earn $8 if hand triggers Boss Blind ability"
+ },
+ {
+ "const": "j_hit_the_road",
+ "description": "Gains X0.5 Mult for every Jack discarded this round"
+ },
+ {
+ "const": "j_duo",
+ "description": "X2 Mult if played hand contains a Pair"
+ },
+ {
+ "const": "j_trio",
+ "description": "X3 Mult if played hand contains a Three of a Kind"
+ },
+ {
+ "const": "j_family",
+ "description": "X4 Mult if played hand contains a Four of a Kind"
+ },
+ {
+ "const": "j_order",
+ "description": "X3 Mult if played hand contains a Straight"
+ },
+ {
+ "const": "j_tribe",
+ "description": "X2 Mult if played hand contains a Flush"
+ },
+ {
+ "const": "j_stuntman",
+ "description": "+250 Chips, -2 hand size"
+ },
+ {
+ "const": "j_invisible",
+ "description": "After 2 rounds, sell to Duplicate a random Joker"
+ },
+ {
+ "const": "j_brainstorm",
+ "description": "Copies the ability of leftmost Joker"
+ },
+ {
+ "const": "j_satellite",
+ "description": "Earn $1 at end of round per unique Planet card used"
+ },
+ {
+ "const": "j_shoot_the_moon",
+ "description": "Each Queen held in hand gives +13 Mult"
+ },
+ {
+ "const": "j_drivers_license",
+ "description": "X3 Mult if you have at least 16 Enhanced cards in deck"
+ },
+ {
+ "const": "j_cartomancer",
+ "description": "Create a Tarot card when Blind is selected"
+ },
+ {
+ "const": "j_astronomer",
+ "description": "All Planet cards and Celestial Packs in shop are free"
+ },
+ {
+ "const": "j_burnt",
+ "description": "Upgrade the level of first discarded poker hand each round"
+ },
+ {
+ "const": "j_bootstraps",
+ "description": "+2 Mult for every $5 you have"
+ },
+ {
+ "const": "j_caino",
+ "description": "Gains X1 Mult when a face card is destroyed"
+ },
+ {
+ "const": "j_triboulet",
+ "description": "Played Kings and Queens each give X2 Mult when scored"
+ },
+ {
+ "const": "j_yorick",
+ "description": "Gains X1 Mult every 23 cards discarded"
+ },
+ {
+ "const": "j_chicot",
+ "description": "Disables effect of every Boss Blind"
+ },
+ {
+ "const": "j_perkeo",
+ "description": "Creates Negative copy of 1 random consumable at end of shop"
+ }
+ ]
+ },
+ "VoucherKey": {
+ "description": "Voucher card key",
+ "oneOf": [
+ {
+ "const": "v_overstock_norm",
+ "description": "Overstock: +1 card slot in shop (to 3)"
+ },
+ {
+ "const": "v_clearance_sale",
+ "description": "Clearance Sale: All cards and packs 25% off"
+ },
+ {
+ "const": "v_hone",
+ "description": "Hone: Foil, Holo, Polychrome appear 2X more often"
+ },
+ {
+ "const": "v_reroll_surplus",
+ "description": "Reroll Surplus: Rerolls cost $2 less"
+ },
+ {
+ "const": "v_crystal_ball",
+ "description": "Crystal Ball: +1 consumable slot"
+ },
+ {
+ "const": "v_telescope",
+ "description": "Telescope: Celestial Packs contain Planet for most played hand"
+ },
+ {
+ "const": "v_grabber",
+ "description": "Grabber: Permanently gain +1 hand per round"
+ },
+ {
+ "const": "v_wasteful",
+ "description": "Wasteful: Permanently gain +1 discard each round"
+ },
+ {
+ "const": "v_tarot_merchant",
+ "description": "Tarot Merchant: Tarot cards appear 2X more in shop"
+ },
+ {
+ "const": "v_planet_merchant",
+ "description": "Planet Merchant: Planet cards appear 2X more in shop"
+ },
+ {
+ "const": "v_seed_money",
+ "description": "Seed Money: Raise interest cap to $10"
+ },
+ {
+ "const": "v_blank",
+ "description": "Blank: Does nothing?"
+ },
+ {
+ "const": "v_magic_trick",
+ "description": "Magic Trick: Playing cards can be purchased from shop"
+ },
+ {
+ "const": "v_hieroglyph",
+ "description": "Hieroglyph: -1 Ante, -1 hand each round"
+ },
+ {
+ "const": "v_directors_cut",
+ "description": "Director's Cut: Reroll Boss Blind 1 time per Ante, $10"
+ },
+ {
+ "const": "v_paint_brush",
+ "description": "Paint Brush: +1 hand size"
+ },
+ {
+ "const": "v_overstock_plus",
+ "description": "Overstock Plus: +1 card slot in shop (to 4)"
+ },
+ {
+ "const": "v_liquidation",
+ "description": "Liquidation: All cards and packs 50% off"
+ },
+ {
+ "const": "v_glow_up",
+ "description": "Glow Up: Foil, Holo, Polychrome appear 4X more often"
+ },
+ {
+ "const": "v_reroll_glut",
+ "description": "Reroll Glut: Rerolls cost additional $2 less"
+ },
+ {
+ "const": "v_omen_globe",
+ "description": "Omen Globe: Spectral cards may appear in Arcana Packs"
+ },
+ {
+ "const": "v_observatory",
+ "description": "Observatory: Planet cards in consumable area give X1.5 Mult"
+ },
+ {
+ "const": "v_nacho_tong",
+ "description": "Nacho Tong: Permanently gain additional +1 hand per round"
+ },
+ {
+ "const": "v_recyclomancy",
+ "description": "Recyclomancy: Permanently gain additional +1 discard"
+ },
+ {
+ "const": "v_tarot_tycoon",
+ "description": "Tarot Tycoon: Tarot cards appear 4X more in shop"
+ },
+ {
+ "const": "v_planet_tycoon",
+ "description": "Planet Tycoon: Planet cards appear 4X more in shop"
+ },
+ {
+ "const": "v_money_tree",
+ "description": "Money Tree: Raise interest cap to $20"
+ },
+ {
+ "const": "v_antimatter",
+ "description": "Antimatter: +1 Joker slot"
+ },
+ {
+ "const": "v_illusion",
+ "description": "Illusion: Playing cards in shop may have Enhancement/Edition/Seal"
+ },
+ {
+ "const": "v_petroglyph",
+ "description": "Petroglyph: -1 Ante again, -1 discard each round"
+ },
+ {
+ "const": "v_retcon",
+ "description": "Retcon: Reroll Boss Blind unlimited times, $10 per roll"
+ },
+ {
+ "const": "v_palette",
+ "description": "Palette: +1 hand size again"
+ }
+ ]
+ },
+ "PlayingCardKey": {
+ "description": "Playing card key in SUIT_RANK format (H=Hearts, D=Diamonds, C=Clubs, S=Spades)",
+ "oneOf": [
+ {
+ "const": "H_2",
+ "description": "Two of Hearts"
+ },
+ {
+ "const": "H_3",
+ "description": "Three of Hearts"
+ },
+ {
+ "const": "H_4",
+ "description": "Four of Hearts"
+ },
+ {
+ "const": "H_5",
+ "description": "Five of Hearts"
+ },
+ {
+ "const": "H_6",
+ "description": "Six of Hearts"
+ },
+ {
+ "const": "H_7",
+ "description": "Seven of Hearts"
+ },
+ {
+ "const": "H_8",
+ "description": "Eight of Hearts"
+ },
+ {
+ "const": "H_9",
+ "description": "Nine of Hearts"
+ },
+ {
+ "const": "H_T",
+ "description": "Ten of Hearts"
+ },
+ {
+ "const": "H_J",
+ "description": "Jack of Hearts"
+ },
+ {
+ "const": "H_Q",
+ "description": "Queen of Hearts"
+ },
+ {
+ "const": "H_K",
+ "description": "King of Hearts"
+ },
+ {
+ "const": "H_A",
+ "description": "Ace of Hearts"
+ },
+ {
+ "const": "D_2",
+ "description": "Two of Diamonds"
+ },
+ {
+ "const": "D_3",
+ "description": "Three of Diamonds"
+ },
+ {
+ "const": "D_4",
+ "description": "Four of Diamonds"
+ },
+ {
+ "const": "D_5",
+ "description": "Five of Diamonds"
+ },
+ {
+ "const": "D_6",
+ "description": "Six of Diamonds"
+ },
+ {
+ "const": "D_7",
+ "description": "Seven of Diamonds"
+ },
+ {
+ "const": "D_8",
+ "description": "Eight of Diamonds"
+ },
+ {
+ "const": "D_9",
+ "description": "Nine of Diamonds"
+ },
+ {
+ "const": "D_T",
+ "description": "Ten of Diamonds"
+ },
+ {
+ "const": "D_J",
+ "description": "Jack of Diamonds"
+ },
+ {
+ "const": "D_Q",
+ "description": "Queen of Diamonds"
+ },
+ {
+ "const": "D_K",
+ "description": "King of Diamonds"
+ },
+ {
+ "const": "D_A",
+ "description": "Ace of Diamonds"
+ },
+ {
+ "const": "C_2",
+ "description": "Two of Clubs"
+ },
+ {
+ "const": "C_3",
+ "description": "Three of Clubs"
+ },
+ {
+ "const": "C_4",
+ "description": "Four of Clubs"
+ },
+ {
+ "const": "C_5",
+ "description": "Five of Clubs"
+ },
+ {
+ "const": "C_6",
+ "description": "Six of Clubs"
+ },
+ {
+ "const": "C_7",
+ "description": "Seven of Clubs"
+ },
+ {
+ "const": "C_8",
+ "description": "Eight of Clubs"
+ },
+ {
+ "const": "C_9",
+ "description": "Nine of Clubs"
+ },
+ {
+ "const": "C_T",
+ "description": "Ten of Clubs"
+ },
+ {
+ "const": "C_J",
+ "description": "Jack of Clubs"
+ },
+ {
+ "const": "C_Q",
+ "description": "Queen of Clubs"
+ },
+ {
+ "const": "C_K",
+ "description": "King of Clubs"
+ },
+ {
+ "const": "C_A",
+ "description": "Ace of Clubs"
+ },
+ {
+ "const": "S_2",
+ "description": "Two of Spades"
+ },
+ {
+ "const": "S_3",
+ "description": "Three of Spades"
+ },
+ {
+ "const": "S_4",
+ "description": "Four of Spades"
+ },
+ {
+ "const": "S_5",
+ "description": "Five of Spades"
+ },
+ {
+ "const": "S_6",
+ "description": "Six of Spades"
+ },
+ {
+ "const": "S_7",
+ "description": "Seven of Spades"
+ },
+ {
+ "const": "S_8",
+ "description": "Eight of Spades"
+ },
+ {
+ "const": "S_9",
+ "description": "Nine of Spades"
+ },
+ {
+ "const": "S_T",
+ "description": "Ten of Spades"
+ },
+ {
+ "const": "S_J",
+ "description": "Jack of Spades"
+ },
+ {
+ "const": "S_Q",
+ "description": "Queen of Spades"
+ },
+ {
+ "const": "S_K",
+ "description": "King of Spades"
+ },
+ {
+ "const": "S_A",
+ "description": "Ace of Spades"
+ }
+ ]
+ },
+ "ConsumableKey": {
+ "description": "Consumable card key (Tarot, Planet, or Spectral)",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/TarotKey"
+ },
+ {
+ "$ref": "#/components/schemas/PlanetKey"
+ },
+ {
+ "$ref": "#/components/schemas/SpectralKey"
+ }
+ ]
+ },
+ "CardKey": {
+ "description": "Card key for the add endpoint. Supports jokers (j_*), consumables (c_*), vouchers (v_*), or playing cards (SUIT_RANK)",
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/JokerKey"
+ },
+ {
+ "$ref": "#/components/schemas/ConsumableKey"
+ },
+ {
+ "$ref": "#/components/schemas/VoucherKey"
+ },
+ {
+ "$ref": "#/components/schemas/PlayingCardKey"
+ }
+ ]
+ }
+ },
+ "errors": {
+ "InternalError": {
+ "code": -32000,
+ "message": "Internal error",
+ "data": {
+ "name": "INTERNAL_ERROR"
+ }
+ },
+ "BadRequest": {
+ "code": -32001,
+ "message": "Bad request",
+ "data": {
+ "name": "BAD_REQUEST"
+ }
+ },
+ "InvalidState": {
+ "code": -32002,
+ "message": "Invalid state",
+ "data": {
+ "name": "INVALID_STATE"
+ }
+ },
+ "NotAllowed": {
+ "code": -32003,
+ "message": "Not allowed",
+ "data": {
+ "name": "NOT_ALLOWED"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/lua/utils/types.lua b/src/lua/utils/types.lua
new file mode 100644
index 0000000..b0151a2
--- /dev/null
+++ b/src/lua/utils/types.lua
@@ -0,0 +1,299 @@
+---@meta types
+
+-- ==========================================================================
+-- GameState Types
+--
+-- The GameState represents the current game state of the game. It's a nested
+-- table that contains all the information about the game state, including
+-- the current round, hand, and discards.
+-- ==========================================================================
+
+---@class GameState
+---@field deck Deck? Current selected deck
+---@field stake Stake? Current selected stake
+---@field seed string? Seed used for the run
+---@field state State Current game state
+---@field round_num integer Current round number
+---@field ante_num integer Current ante number
+---@field money integer Current money amount
+---@field used_vouchers table? Vouchers used (name -> description)
+---@field hands table? Poker hands information
+---@field round Round? Current round state
+---@field blinds table<"small"|"big"|"boss", Blind>? Blind information
+---@field jokers Area? Jokers area
+---@field consumables Area? Consumables area
+---@field hand Area? Hand area (available during playing phase)
+---@field pack Area? Currently open pack (available during opeing pack phase)
+---@field shop Area? Shop area (available during shop phase)
+---@field vouchers Area? Vouchers area (available during shop phase)
+---@field packs Area? Booster packs area (available during shop phase)
+---@field won boolean? Whether the game has been won
+
+---@class Hand
+---@field order integer The importance/ordering of the hand
+---@field level integer Level of the hand in the current run
+---@field chips integer Current chip value for this hand
+---@field mult integer Current multiplier value for this hand
+---@field played integer Total number of times this hand has been played
+---@field played_this_round integer Number of times this hand has been played this round
+---@field example table Example cards showing what makes this hand (array of [card_key, is_scored])
+
+---@class Round
+---@field hands_left integer? Number of hands remaining in this round
+---@field hands_played integer? Number of hands played in this round
+---@field discards_left integer? Number of discards remaining in this round
+---@field discards_used integer? Number of discards used in this round
+---@field reroll_cost integer? Current cost to reroll the shop
+---@field chips integer? Current chips scored in this round
+
+---@class Blind
+---@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)
+
+---@class Area
+---@field count integer Current number of cards in this area
+---@field limit integer Maximum number of cards allowed in this area
+---@field highlighted_limit integer? Maximum number of cards that can be highlighted (hand area only)
+---@field cards Card[] Array of cards in this area
+
+---@class Card
+---@field id integer Unique identifier for the card (sort_id)
+---@field key Card.Key Specific card key (e.g., "c_fool", "j_brainstorm, "v_overstock", ...)
+---@field set Card.Set Card set/type
+---@field label string Display label/name of the card
+---@field value Card.Value Value information for the card
+---@field modifier Card.Modifier Modifier information (seals, editions, enhancements)
+---@field state Card.State Current state information (debuff, hidden, highlighted)
+---@field cost Card.Cost Cost information (buy/sell prices)
+
+---@class Card.Value
+---@field suit Card.Value.Suit? Suit (Hearts, Diamonds, Clubs, Spades) - only for playing cards
+---@field rank Card.Value.Rank? Rank - only for playing cards
+---@field effect string Description of the card's effect (from UI)
+
+---@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 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)
+---@field rental boolean? If true, card costs money at end of round (jokers only)
+
+---@class Card.State
+---@field debuff boolean? If true, card is debuffed and won't score
+---@field hidden boolean? If true, card is face down (facing == "back")
+---@field highlight boolean? If true, card is currently highlighted
+
+---@class Card.Cost
+---@field sell integer Sell value of the card
+---@field buy integer Buy price of the card (if in shop)
+
+-- ==========================================================================
+-- Endpoint Types
+--
+-- The endpoints are registered at initialization. The dispatcher will redirect
+-- requests to the correct endpoint based on the Request.Endpoint.Method (and
+-- Request.Endpoint.Test.Method). The validator check that the game is in the
+-- correct state and check that the provided Request.Endpoint.Params follow the
+-- endpoint schema. Finally, the endpoint execute function is called with the
+-- Request.Endpoint.Params (or Request.Endpoint.Test.Params).
+-- ==========================================================================
+
+---@class Endpoint
+---@field name string The endpoint name
+---@field description string Brief description of the endpoint
+---@field schema table Schema definition for arguments validation
+---@field requires_state integer[]? Optional list of required game states
+---@field execute fun(args: Request.Endpoint.Params | Request.Endpoint.Test.Params, send_response: fun(response: Response.Endpoint)) Execute function
+
+---@class Endpoint.Schema
+---@field type "string"|"integer"|"array"|"boolean"|"table"
+---@field required boolean?
+---@field items "integer"?
+---@field description string
+
+-- ==========================================================================
+-- Server Request Type
+--
+-- The Request.Server is the JSON-RPC 2.0 request received by the server and
+-- used by the dispatcher to call the right endpoint with the correct
+-- arguments.
+-- ==========================================================================
+
+---@class Request.Server
+---@field jsonrpc "2.0"
+---@field method Request.Endpoint.Method | Request.Endpoint.Test.Method Request method name.
+---@field params Request.Endpoint.Params | Request.Endpoint.Test.Params Params to use for the requests
+---@field id integer|string Request ID (required)
+
+-- ==========================================================================
+-- Endpoint Request Types
+--
+-- The Request.Endpoint.Method (and Request.Endpoint.Test.Method) specifies
+-- the endpoint name. The Request.Endpoint.Params (and Request.Endpoint.Test.Params)
+-- contains the arguments to use in the endpoint execute function.
+-- ==========================================================================
+
+---@alias Request.Endpoint.Method
+---| "add" | "buy" | "cash_out" | "discard" | "gamestate" | "health" | "load"
+---| "menu" | "next_round" | "play" | "rearrange" | "reroll" | "save"
+---| "screenshot" | "select" | "sell" | "set" | "skip" | "start" | "use"
+
+---@alias Request.Endpoint.Test.Method
+---| "echo" | "endpoint" | "error" | "state" | "validation"
+
+---@alias Request.Endpoint.Params
+---| Request.Endpoint.Add.Params
+---| Request.Endpoint.Buy.Params
+---| Request.Endpoint.CashOut.Params
+---| Request.Endpoint.Discard.Params
+---| Request.Endpoint.Gamestate.Params
+---| Request.Endpoint.Health.Params
+---| Request.Endpoint.Load.Params
+---| Request.Endpoint.Menu.Params
+---| Request.Endpoint.NextRound.Params
+---| Request.Endpoint.Play.Params
+---| Request.Endpoint.Rearrange.Params
+---| Request.Endpoint.Reroll.Params
+---| Request.Endpoint.Save.Params
+---| Request.Endpoint.Screenshot.Params
+---| Request.Endpoint.Select.Params
+---| Request.Endpoint.Sell.Params
+---| Request.Endpoint.Set.Params
+---| Request.Endpoint.Skip.Params
+---| Request.Endpoint.Start.Params
+---| Request.Endpoint.Use.Params
+
+---@alias Request.Endpoint.Test.Params
+---| Request.Endpoint.Test.Echo.Params
+---| Request.Endpoint.Test.Endpoint.Params
+---| Request.Endpoint.Test.Error.Params
+---| Request.Endpoint.Test.State.Params
+---| Request.Endpoint.Test.Validation.Params
+
+-- ==========================================================================
+-- Endpoint Response Types
+--
+-- The execute function terminates with the excecution of the callback function
+-- `send_response`. The `send_respnose` function takes as input a
+-- Response.Endpoint (which is not JSON-RPC 2.0 compliant).
+-- ==========================================================================
+
+---@class Response.Endpoint.Path
+---@field success boolean Whether the request was successful
+---@field path string Path to the file
+
+---@class Response.Endpoint.Health
+---@field status "ok"
+
+---@alias Response.Endpoint.GameState
+---| GameState # Return the current game state of the game
+
+---@class Response.Endpoint.Test
+---@field success boolean Whether the request was successful
+---@field received_args table? Arguments received by the endpoint (for test endpoints)
+---@field state_validated boolean? Whether the state was validated (for test endpoints)
+
+---@class Response.Endpoint.Error
+---@field message string Human-readable error message
+---@field name ErrorName Error name (BAD_REQUEST, INVALID_STATE, etc.)
+
+---@alias Response.Endpoint
+---| Response.Endpoint.Health
+---| Response.Endpoint.Path
+---| Response.Endpoint.GameState
+---| Response.Endpoint.Test
+---| Response.Endpoint.Error
+
+-- ==========================================================================
+-- Server Response Types
+--
+-- The `send_response` transforms the Response.Endpoint into a JSON-RPC 2.0
+-- compliant response returning to the client a Response.Server
+-- ==========================================================================
+
+---@class Response.Server.Success
+---@field jsonrpc "2.0"
+---@field result Response.Endpoint.Health | Response.Endpoint.Path | Response.Endpoint.GameState | Response.Endpoint.Test Response payload
+---@field id integer|string Request ID (echoed from request)
+
+---@class Response.Server.Error
+---@field jsonrpc "2.0"
+---@field error Response.Server.Error.Error Response error
+---@field id integer|string|nil Request ID (null only if request was unparseable)
+
+---@class Response.Server.Error.Error
+---@field code ErrorCode Numeric error code following JSON-RPC 2.0 convention
+---@field message string Human-readable error message
+---@field data table<'name', ErrorName> Semantic error code
+
+---@alias Response.Server
+---| Response.Server.Success
+---| Response.Server.Error
+
+-- ==========================================================================
+-- Error Types
+-- ==========================================================================
+
+---@alias ErrorName
+---| "BAD_REQUEST" Client sent invalid data (protocol/parameter errors)
+---| "INVALID_STATE" Action not allowed in current game state
+---| "NOT_ALLOWED" Game rules prevent this action
+---| "INTERNAL_ERROR" Server-side failure (runtime/execution errors)
+
+---@alias ErrorCode
+---| -32000 # INTERNAL_ERROR
+---| -32001 # BAD_REQUEST
+---| -32002 # INVALID_STATE
+---| -32003 # NOT_ALLOWED
+
+---@alias ErrorNames table
+---@alias ErrorCodes table
+
+-- ==========================================================================
+-- Core Infrastructure Types
+-- ==========================================================================
+
+---@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 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)
+
+---@class Debug
+---@field log table? DebugPlus logger instance with debug/info/error methods (nil if DebugPlus not available)
+
+---@class Server
+---@field host string Hostname for the HTTP server (copied from Settings)
+---@field port integer Port number for the HTTP server (copied from Settings)
+---@field server_socket TCPSocketServer? Underlying TCP socket listening for HTTP connections (nil if not initialized)
+---@field client_socket TCPSocketClient? Underlying TCP socket for the connected HTTP client (nil if no client connected)
+---@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 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
+---@field update? fun(dispatcher: Dispatcher) Main update loop - parse HTTP requests and dispatch JSON-RPC calls each frame
+---@field close? fun() Close HTTP server and all connections
+
+---@class Dispatcher
+---@field endpoints table Map of endpoint names to Endpoint definitions (registered at initialization)
+---@field Server Server? Reference to the Server module for sending responses (set during initialization)
+---@field register? fun(endpoint: Endpoint): boolean, string? Register a new endpoint (returns success, error_message)
+---@field load_endpoints? fun(endpoint_files: string[]): boolean, string? Load and register endpoints from files (returns success, error_message)
+---@field init? fun(server_module: table, endpoint_files: string[]?): boolean Initialize dispatcher with server reference and endpoint files
+---@field send_error? fun(message: string, error_code: string) Send error response using server
+---@field dispatch? fun(parsed: Request.Server) Dispatch JSON-RPC request to appropriate endpoint
+
+---@class Validator
+---@field validate fun(args: table, schema: table): boolean, string?, string? Validates endpoint arguments against schema (returns success, error_message, error_code)
diff --git a/.gitmodules b/tests/__init__.py
similarity index 100%
rename from .gitmodules
rename to tests/__init__.py
diff --git a/tests/balatrobot/conftest.py b/tests/balatrobot/conftest.py
deleted file mode 100644
index 1ae50b9..0000000
--- a/tests/balatrobot/conftest.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""BalatroClient-specific test configuration and fixtures."""
-
-import pytest
-
-from balatrobot.client import BalatroClient
-from balatrobot.enums import State
-from balatrobot.exceptions import BalatroError, ConnectionFailedError
-from balatrobot.models import G
-
-
-@pytest.fixture(scope="function", autouse=True)
-def reset_game_to_menu(port):
- """Reset game to menu state before each test."""
- try:
- with BalatroClient(port=port) as client:
- response = client.send_message("go_to_menu", {})
- game_state = G.model_validate(response)
- assert game_state.state_enum == State.MENU
- except (ConnectionFailedError, BalatroError):
- # Game not running or other API error, skip setup
- pass
diff --git a/tests/balatrobot/test_client.py b/tests/balatrobot/test_client.py
deleted file mode 100644
index 0734b59..0000000
--- a/tests/balatrobot/test_client.py
+++ /dev/null
@@ -1,497 +0,0 @@
-"""Tests for the BalatroClient class using real Game API."""
-
-import json
-import socket
-from unittest.mock import Mock
-
-import pytest
-
-from balatrobot.client import BalatroClient
-from balatrobot.exceptions import BalatroError, ConnectionFailedError
-from balatrobot.models import G
-
-
-class TestBalatroClient:
- """Test suite for BalatroClient with real Game API."""
-
- def test_client_initialization_defaults(self, port):
- """Test client initialization with default class attributes."""
- client = BalatroClient(port=port)
-
- assert client.host == "127.0.0.1"
- assert client.port == port
- assert client.timeout == 300.0
- assert client.buffer_size == 65536
- assert client._socket is None
- assert client._connected is False
-
- def test_client_class_attributes(self):
- """Test client class attributes are set correctly."""
- assert BalatroClient.host == "127.0.0.1"
- assert BalatroClient.timeout == 300.0
- assert BalatroClient.buffer_size == 65536
-
- def test_custom_timeout_parameter(self):
- """Test that custom timeout parameter can be set."""
- custom_timeout = 120.0
- client = BalatroClient(port=12346, timeout=custom_timeout)
-
- assert client.timeout == custom_timeout
-
- def test_none_timeout_uses_default(self):
- """Test that None timeout uses the class default."""
- client = BalatroClient(port=12346, timeout=None)
-
- assert client.timeout == 300.0
-
- def test_context_manager_with_game_running(self, port):
- """Test context manager functionality with game running."""
- with BalatroClient(port=port) as client:
- assert client._connected is True
- assert client._socket is not None
-
- # Test that we can get game state
- response = client.send_message("get_game_state", {})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_manual_connect_disconnect_with_game_running(self, port):
- """Test manual connection and disconnection with game running."""
- client = BalatroClient(port=port)
-
- # Test connection
- client.connect()
- assert client._connected is True
- assert client._socket is not None
-
- # Test that we can get game state
- response = client.send_message("get_game_state", {})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test disconnection
- client.disconnect()
- assert client._connected is False
- assert client._socket is None
-
- def test_get_game_state_with_game_running(self, port):
- """Test getting game state with game running."""
- with BalatroClient(port=port) as client:
- response = client.send_message("get_game_state", {})
- game_state = G.model_validate(response)
-
- assert isinstance(game_state, G)
- assert hasattr(game_state, "state")
-
- def test_go_to_menu_with_game_running(self, port):
- """Test going to menu with game running."""
- with BalatroClient(port=port) as client:
- # Test go_to_menu from any state
- response = client.send_message("go_to_menu", {})
- game_state = G.model_validate(response)
-
- assert isinstance(game_state, G)
- assert hasattr(game_state, "state")
-
- def test_double_connect_is_safe(self, port):
- """Test that calling connect twice is safe."""
- client = BalatroClient(port=port)
-
- client.connect()
- assert client._connected is True
-
- # Second connect should be safe
- client.connect()
- assert client._connected is True
-
- client.disconnect()
-
- def test_disconnect_when_not_connected(self, port):
- """Test that disconnecting when not connected is safe."""
- client = BalatroClient(port=port)
-
- # Should not raise any exceptions
- client.disconnect()
- assert client._connected is False
- assert client._socket is None
-
- def test_connection_failure_wrong_port(self):
- """Test connection failure with wrong port."""
- client = BalatroClient(port=54321) # Use invalid port directly
-
- with pytest.raises(ConnectionFailedError) as exc_info:
- client.connect()
-
- assert "Failed to connect to 127.0.0.1:54321" in str(exc_info.value)
- assert exc_info.value.error_code.value == "E008"
-
- def test_send_message_when_not_connected(self, port):
- """Test sending message when not connected raises error."""
- client = BalatroClient(port=port)
-
- with pytest.raises(ConnectionFailedError) as exc_info:
- client.send_message("get_game_state", {})
-
- assert "Not connected to the game API" in str(exc_info.value)
- assert exc_info.value.error_code.value == "E008"
-
- def test_socket_configuration(self, port):
- """Test socket is configured correctly."""
- client = BalatroClient(port=port)
- # Temporarily change timeout and buffer_size
- original_timeout = client.timeout
- original_buffer_size = client.buffer_size
- client.timeout = 5.0
- client.buffer_size = 32768
-
- with client:
- sock = client._socket
-
- assert sock is not None
- assert sock.gettimeout() == 5.0
- # Note: OS may adjust buffer size, so we check it's at least the requested size
- assert sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) >= 32768
-
- # Restore original values
- client.timeout = original_timeout
- client.buffer_size = original_buffer_size
-
- def test_start_run_with_game_running(self, port):
- """Test start_run method with game running."""
- with BalatroClient(port=port) as client:
- # Test with minimal parameters
- response = client.send_message(
- "start_run", {"deck": "Red Deck", "seed": "OOOO155"}
- )
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test with all parameters
- response = client.send_message(
- "start_run",
- {
- "deck": "Blue Deck",
- "stake": 2,
- "seed": "OOOO155",
- "challenge": "test_challenge",
- },
- )
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_skip_or_select_blind_with_game_running(self, port):
- """Test skip_or_select_blind method with game running."""
- with BalatroClient(port=port) as client:
- # First start a run to get to blind selection state
- response = client.send_message("start_run", {"deck": "Red Deck"})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test skip action
- response = client.send_message("skip_or_select_blind", {"action": "skip"})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test select action
- response = client.send_message("skip_or_select_blind", {"action": "select"})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_play_hand_or_discard_with_game_running(self, port):
- """Test play_hand_or_discard method with game running."""
- with BalatroClient(port=port) as client:
- # Test play_hand action - may fail if not in correct game state
- try:
- response = client.send_message(
- "play_hand_or_discard", {"action": "play_hand", "cards": [0, 1, 2]}
- )
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in selecting hand state
- pass
-
- # Test discard action - may fail if not in correct game state
- try:
- response = client.send_message(
- "play_hand_or_discard", {"action": "discard", "cards": [0]}
- )
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in selecting hand state
- pass
-
- def test_cash_out_with_game_running(self, port):
- """Test cash_out method with game running."""
- with BalatroClient(port=port) as client:
- try:
- response = client.send_message("cash_out", {})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in correct state for cash out
- pass
-
- def test_shop_with_game_running(self, port):
- """Test shop method with game running."""
- with BalatroClient(port=port) as client:
- try:
- response = client.send_message("shop", {"action": "next_round"})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in shop state
- pass
-
- def test_send_message_api_error_response(self, port):
- """Test send_message handles API error responses correctly."""
- client = BalatroClient(port=port)
-
- # Mock socket to return an error response
- mock_socket = Mock()
- error_response = {
- "error": "Invalid game state",
- "error_code": "E009",
- "state": 1,
- "context": {"expected": "MENU", "actual": "SHOP"},
- }
- mock_socket.recv.return_value = json.dumps(error_response).encode()
-
- client._socket = mock_socket
- client._connected = True
-
- with pytest.raises(BalatroError) as exc_info:
- client.send_message("invalid_function", {})
-
- assert "Invalid game state" in str(exc_info.value)
- assert exc_info.value.error_code.value == "E009"
-
- def test_send_message_socket_error(self, port):
- """Test send_message handles socket errors correctly."""
- client = BalatroClient(port=port)
-
- # Mock socket to raise socket error
- mock_socket = Mock()
- mock_socket.send.side_effect = socket.error("Connection broken")
-
- client._socket = mock_socket
- client._connected = True
-
- with pytest.raises(ConnectionFailedError) as exc_info:
- client.send_message("test_function", {})
-
- assert "Socket error during communication" in str(exc_info.value)
- assert exc_info.value.error_code.value == "E008"
-
- def test_send_message_json_decode_error(self, port):
- """Test send_message handles JSON decode errors correctly."""
- client = BalatroClient(port=port)
-
- # Mock socket to return invalid JSON
- mock_socket = Mock()
- mock_socket.recv.return_value = b"invalid json response"
-
- client._socket = mock_socket
- client._connected = True
-
- with pytest.raises(BalatroError) as exc_info:
- client.send_message("test_function", {})
-
- assert "Invalid JSON response from game" in str(exc_info.value)
- assert exc_info.value.error_code.value == "E001"
-
- def test_send_message_successful_response(self, port):
- """Test send_message with successful responses."""
- client = BalatroClient(port=port)
-
- # Mock successful responses for each API method
- success_response = {
- "state": 1,
- "game": {"chips": 100, "dollars": 4},
- "hand": [],
- "jokers": [],
- }
-
- mock_socket = Mock()
- mock_socket.recv.return_value = json.dumps(success_response).encode()
-
- client._socket = mock_socket
- client._connected = True
-
- # Test skip_or_select_blind success
- response = client.send_message("skip_or_select_blind", {"action": "skip"})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test play_hand_or_discard success
- response = client.send_message(
- "play_hand_or_discard", {"action": "play_hand", "cards": [0, 1]}
- )
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test cash_out success
- response = client.send_message("cash_out", {})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- # Test shop success
- response = client.send_message("shop", {"action": "next_round"})
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
-
-class TestSendMessageAPIFunctions:
- """Test suite for all API functions using send_message method."""
-
- def test_send_message_get_game_state(self, port):
- """Test send_message with get_game_state function."""
- with BalatroClient(port=port) as client:
- response = client.send_message("get_game_state", {})
-
- # Response should be a dict that can be validated as G
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- assert hasattr(game_state, "state")
-
- def test_send_message_go_to_menu(self, port):
- """Test send_message with go_to_menu function."""
- with BalatroClient(port=port) as client:
- response = client.send_message("go_to_menu", {})
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- assert hasattr(game_state, "state")
-
- def test_send_message_start_run_minimal(self, port):
- """Test send_message with start_run function (minimal parameters)."""
- with BalatroClient(port=port) as client:
- response = client.send_message("start_run", {"deck": "Red Deck"})
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_send_message_start_run_with_all_params(self, port):
- """Test send_message with start_run function (all parameters)."""
- with BalatroClient(port=port) as client:
- response = client.send_message(
- "start_run",
- {
- "deck": "Blue Deck",
- "stake": 2,
- "seed": "OOOO155",
- "challenge": "test_challenge",
- },
- )
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_send_message_skip_or_select_blind_skip(self, port):
- """Test send_message with skip_or_select_blind function (skip action)."""
- with BalatroClient(port=port) as client:
- # First start a run to get to blind selection state
- client.send_message("start_run", {"deck": "Red Deck"})
-
- response = client.send_message("skip_or_select_blind", {"action": "skip"})
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_send_message_skip_or_select_blind_select(self, port):
- """Test send_message with skip_or_select_blind function (select action)."""
- with BalatroClient(port=port) as client:
- # First start a run to get to blind selection state
- client.send_message("start_run", {"deck": "Red Deck"})
-
- response = client.send_message("skip_or_select_blind", {"action": "select"})
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
-
- def test_send_message_play_hand_or_discard_play_hand(self, port):
- """Test send_message with play_hand_or_discard function (play_hand action)."""
- with BalatroClient(port=port) as client:
- # This may fail if not in correct game state - expected behavior
- try:
- response = client.send_message(
- "play_hand_or_discard", {"action": "play_hand", "cards": [0, 1, 2]}
- )
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in selecting hand state
- pass
-
- def test_send_message_play_hand_or_discard_discard(self, port):
- """Test send_message with play_hand_or_discard function (discard action)."""
- with BalatroClient(port=port) as client:
- # This may fail if not in correct game state - expected behavior
- try:
- response = client.send_message(
- "play_hand_or_discard", {"action": "discard", "cards": [0]}
- )
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in selecting hand state
- pass
-
- def test_send_message_cash_out(self, port):
- """Test send_message with cash_out function."""
- with BalatroClient(port=port) as client:
- try:
- response = client.send_message("cash_out", {})
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in correct state for cash out
- pass
-
- def test_send_message_shop_next_round(self, port):
- """Test send_message with shop function."""
- with BalatroClient(port=port) as client:
- try:
- response = client.send_message("shop", {"action": "next_round"})
-
- assert isinstance(response, dict)
- game_state = G.model_validate(response)
- assert isinstance(game_state, G)
- except BalatroError:
- # Expected if game is not in shop state
- pass
-
- def test_send_message_invalid_function_name(self, port):
- """Test send_message with invalid function name raises error."""
- with BalatroClient(port=port) as client:
- with pytest.raises(BalatroError):
- client.send_message("invalid_function", {})
-
- def test_send_message_missing_required_arguments(self, port):
- """Test send_message with missing required arguments raises error."""
- with BalatroClient(port=port) as client:
- # start_run requires deck parameter
- with pytest.raises(BalatroError):
- client.send_message("start_run", {})
-
- def test_send_message_invalid_arguments(self, port):
- """Test send_message with invalid arguments raises error."""
- with BalatroClient(port=port) as client:
- # Invalid action for skip_or_select_blind
- with pytest.raises(BalatroError):
- client.send_message(
- "skip_or_select_blind", {"action": "invalid_action"}
- )
diff --git a/tests/balatrobot/test_exceptions.py b/tests/balatrobot/test_exceptions.py
deleted file mode 100644
index cff1cc3..0000000
--- a/tests/balatrobot/test_exceptions.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Tests for exception handling and error response creation."""
-
-from balatrobot.enums import ErrorCode
-from balatrobot.exceptions import (
- BalatroError,
- ConnectionFailedError,
- InvalidJSONError,
- create_exception_from_error_response,
-)
-
-
-class TestBalatroError:
- """Test suite for BalatroError base class."""
-
- def test_repr_method(self):
- """Test __repr__ method returns correct string representation."""
- error = BalatroError(
- message="Test error message",
- error_code=ErrorCode.INVALID_JSON,
- state=5,
- )
-
- expected = (
- "BalatroError(message='Test error message', error_code='E001', state=5)"
- )
- assert repr(error) == expected
-
- def test_repr_method_with_none_state(self):
- """Test __repr__ method with None state."""
- error = BalatroError(
- message="Test error",
- error_code="E008",
- state=None,
- )
-
- expected = "BalatroError(message='Test error', error_code='E008', state=None)"
- assert repr(error) == expected
-
-
-class TestCreateExceptionFromErrorResponse:
- """Test suite for create_exception_from_error_response function."""
-
- def test_create_exception_with_context(self):
- """Test creating exception with context field present."""
- error_response = {
- "error": "Connection failed",
- "error_code": "E008",
- "state": 1,
- "context": {"host": "127.0.0.1", "port": 12346},
- }
-
- exception = create_exception_from_error_response(error_response)
-
- assert isinstance(exception, ConnectionFailedError)
- assert exception.message == "Connection failed"
- assert exception.error_code == ErrorCode.CONNECTION_FAILED
- assert exception.state == 1
- assert exception.context == {"host": "127.0.0.1", "port": 12346}
-
- def test_create_exception_without_context(self):
- """Test creating exception without context field."""
- error_response = {
- "error": "Invalid JSON format",
- "error_code": "E001",
- "state": 11,
- }
-
- exception = create_exception_from_error_response(error_response)
-
- assert isinstance(exception, InvalidJSONError)
- assert exception.message == "Invalid JSON format"
- assert exception.error_code == ErrorCode.INVALID_JSON
- assert exception.state == 11
- assert exception.context == {}
-
- def test_create_exception_with_different_error_code(self):
- """Test creating exception with different error code."""
- error_response = {
- "error": "Invalid parameter",
- "error_code": "E010",
- "state": 2,
- }
-
- exception = create_exception_from_error_response(error_response)
-
- # Should create the correct exception type based on error code
- assert hasattr(exception, "message")
- assert exception.message == "Invalid parameter"
- assert exception.error_code.value == "E010"
- assert exception.state == 2
diff --git a/tests/balatrobot/test_models.py b/tests/balatrobot/test_models.py
deleted file mode 100644
index d87c661..0000000
--- a/tests/balatrobot/test_models.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Tests for Pydantic models and custom properties."""
-
-import pytest
-
-from balatrobot.enums import State
-from balatrobot.models import G
-
-
-class TestGameState:
- """Test suite for G model."""
-
- def test_state_enum_property(self):
- """Test state_enum property converts integer to State enum correctly."""
- # Test with valid state value
- game_state = G(state=1, game=None, hand=None)
- assert game_state.state_enum == State.SELECTING_HAND
-
- # Test with different state values
- game_state = G(state=11, game=None, hand=None)
- assert game_state.state_enum == State.MENU
-
- game_state = G(state=5, game=None, hand=None)
- assert game_state.state_enum == State.SHOP
-
- def test_state_enum_property_with_invalid_state(self):
- """Test state_enum property with invalid state value raises ValueError."""
- game_state = G(state=999, game=None, hand=None) # Invalid state
-
- with pytest.raises(ValueError):
- _ = game_state.state_enum
diff --git a/tests/fixtures/fixtures.json b/tests/fixtures/fixtures.json
new file mode 100644
index 0000000..a5e9673
--- /dev/null
+++ b/tests/fixtures/fixtures.json
@@ -0,0 +1,1879 @@
+{
+ "$schema": "https://gist.githubusercontent.com/S1M0N38/f0fafbc76e1b057820533582276d7fec/raw/a7d0937b79351945022ec3f254355560bb222930/fixtures.schema.json",
+ "health": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ },
+ "gamestate": {
+ "state-BLIND_SELECT--round_num-0--deck-RED--stake-WHITE": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ },
+ "save": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ },
+ "load": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ },
+ "set": {
+ "state-SELECTING_HAND": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SHOP": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ]
+ },
+ "menu": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ },
+ "start": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ },
+ "skip": {
+ "state-BLIND_SELECT--blinds.small.status-SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-BLIND_SELECT--blinds.big.status-SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "skip",
+ "params": {}
+ }
+ ],
+ "state-BLIND_SELECT--blinds.boss.status-SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "skip",
+ "params": {}
+ },
+ {
+ "method": "skip",
+ "params": {}
+ }
+ ]
+ },
+ "select": {
+ "state-BLIND_SELECT--blinds.small.status-SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-BLIND_SELECT--blinds.big.status-SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "skip",
+ "params": {}
+ }
+ ],
+ "state-BLIND_SELECT--blinds.boss.status-SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "skip",
+ "params": {}
+ },
+ {
+ "method": "skip",
+ "params": {}
+ }
+ ]
+ },
+ "play": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SELECTING_HAND": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SELECTING_HAND--round.chips-200": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 200,
+ "hands": 1,
+ "discards": 0
+ }
+ }
+ ],
+ "state-SELECTING_HAND--round.hands_left-1": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "hands": 1
+ }
+ }
+ ],
+ "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "skip",
+ "params": {}
+ },
+ {
+ "method": "skip",
+ "params": {}
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "ante": 8,
+ "chips": 1000000
+ }
+ }
+ ]
+ },
+ "discard": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SELECTING_HAND": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SELECTING_HAND--round.discards_left-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "discards": 0
+ }
+ }
+ ]
+ },
+ "cash_out": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-ROUND_EVAL": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ }
+ ]
+ },
+ "next_round": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SHOP": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ]
+ },
+ "reroll": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SHOP": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ],
+ "state-SHOP--money-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "money": 0
+ }
+ }
+ ]
+ },
+ "buy": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SHOP--shop.cards[0].set-JOKER": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ],
+ "state-SHOP--shop.cards[1].set-PLANET": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ],
+ "state-SHOP--voucher.cards[0].set-VOUCHER": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ],
+ "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ }
+ ],
+ "state-SHOP--voucher.count-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "buy",
+ "params": {
+ "voucher": 0
+ }
+ }
+ ],
+ "state-SHOP--shop.cards[1].set-TAROT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "reroll",
+ "params": {}
+ }
+ ],
+ "state-SHOP--shop.count-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "buy",
+ "params": {
+ "card": 0
+ }
+ },
+ {
+ "method": "buy",
+ "params": {
+ "card": 0
+ }
+ }
+ ],
+ "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "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
+ }
+ }
+ ],
+ "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "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": 1
+ }
+ },
+ {
+ "method": "reroll",
+ "params": {}
+ },
+ {
+ "method": "buy",
+ "params": {
+ "card": 1
+ }
+ },
+ {
+ "method": "reroll",
+ "params": {}
+ },
+ {
+ "method": "reroll",
+ "params": {}
+ },
+ {
+ "method": "reroll",
+ "params": {}
+ }
+ ],
+ "state-SHOP--money-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "money": 0
+ }
+ }
+ ]
+ },
+ "rearrange": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SELECTING_HAND--hand.count-8": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SHOP--jokers.count-4--consumables.count-2": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "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": "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": {
+ "card": 0
+ }
+ }
+ ]
+ },
+ "sell": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-ROUND_EVAL": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ }
+ ],
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SHOP--jokers.count-1--consumables.count-1": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "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": "buy",
+ "params": {
+ "card": 0
+ }
+ }
+ ],
+ "state-SELECTING_HAND--jokers.count-1--consumables.count-1": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "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": "buy",
+ "params": {
+ "card": 0
+ }
+ },
+ {
+ "method": "next_round",
+ "params": {}
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ]
+ },
+ "add": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "buy",
+ "params": {
+ "voucher": 0
+ }
+ }
+ ]
+ },
+ "use": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ],
+ "state-ROUND_EVAL": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000,
+ "money": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ }
+ ],
+ "state-SELECTING_HAND--money-12--consumables.cards[0]-key-c_hermit": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "money": 12
+ }
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_hermit"
+ }
+ }
+ ],
+ "state-SELECTING_HAND--consumables.cards[0]-key-c_familiar": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_familiar"
+ }
+ }
+ ],
+ "state-SHOP--consumables.cards[0]-key-c_familiar": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_familiar"
+ }
+ }
+ ],
+ "state-SELECTING_HAND--consumables.cards[0]-key-c_temperance--jokers.count-0": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_temperance"
+ }
+ }
+ ],
+ "state-SHOP--money-12--consumables.cards[0]-key-c_hermit": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "money": 12
+ }
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_hermit"
+ }
+ }
+ ],
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "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": 1
+ }
+ },
+ {
+ "method": "reroll",
+ "params": {}
+ },
+ {
+ "method": "buy",
+ "params": {
+ "card": 1
+ }
+ },
+ {
+ "method": "next_round",
+ "params": {}
+ },
+ {
+ "method": "select",
+ "params": {}
+ }
+ ],
+ "state-SELECTING_HAND--consumables.cards[0].key-c_death": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_death"
+ }
+ }
+ ],
+ "state-SHOP--consumables.cards[0].key-c_magician": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000,
+ "money": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_magician"
+ }
+ }
+ ],
+ "state-SHOP--consumables.cards[0].key-c_strength": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ },
+ {
+ "method": "select",
+ "params": {}
+ },
+ {
+ "method": "set",
+ "params": {
+ "chips": 1000,
+ "money": 1000
+ }
+ },
+ {
+ "method": "play",
+ "params": {
+ "cards": [
+ 0
+ ]
+ }
+ },
+ {
+ "method": "cash_out",
+ "params": {}
+ },
+ {
+ "method": "add",
+ "params": {
+ "key": "c_strength"
+ }
+ }
+ ]
+ },
+ "screenshot": {
+ "state-BLIND_SELECT": [
+ {
+ "method": "menu",
+ "params": {}
+ },
+ {
+ "method": "start",
+ "params": {
+ "deck": "RED",
+ "stake": "WHITE",
+ "seed": "TEST123"
+ }
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/generate.py b/tests/fixtures/generate.py
new file mode 100644
index 0000000..773722f
--- /dev/null
+++ b/tests/fixtures/generate.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+
+import json
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+
+import httpx
+from tqdm import tqdm
+
+FIXTURES_DIR = Path(__file__).parent
+HOST = "127.0.0.1"
+PORT = 12346
+
+# JSON-RPC 2.0 request ID counter
+_request_id: int = 0
+
+
+@dataclass
+class FixtureSpec:
+ paths: list[Path]
+ setup: list[tuple[str, dict]]
+
+
+def api(client: httpx.Client, method: str, params: dict) -> dict:
+ """Send a JSON-RPC 2.0 request to BalatroBot."""
+ global _request_id
+ _request_id += 1
+
+ payload = {
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ "id": _request_id,
+ }
+
+ response = client.post("/", json=payload)
+ response.raise_for_status()
+ data = response.json()
+
+ # Handle JSON-RPC 2.0 error responses
+ if "error" in data:
+ return {"error": data["error"]}
+
+ return data.get("result", {})
+
+
+def corrupt_file(path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(b"CORRUPTED_SAVE_FILE_FOR_TESTING\x00\x01\x02")
+
+
+def load_fixtures_json() -> dict:
+ with open(FIXTURES_DIR / "fixtures.json") as f:
+ return json.load(f)
+
+
+def steps_to_setup(steps: list[dict]) -> list[tuple[str, dict]]:
+ return [(step["method"], step["params"]) for step in steps]
+
+
+def steps_to_key(steps: list[dict]) -> str:
+ return json.dumps(steps, sort_keys=True, separators=(",", ":"))
+
+
+def aggregate_fixtures(json_data: dict) -> list[FixtureSpec]:
+ setup_to_paths: dict[str, list[Path]] = defaultdict(list)
+ setup_to_steps: dict[str, list[dict]] = {}
+
+ for group_name, fixtures in json_data.items():
+ if group_name == "$schema":
+ continue
+
+ for fixture_name, steps in fixtures.items():
+ path = FIXTURES_DIR / group_name / f"{fixture_name}.jkr"
+ key = steps_to_key(steps)
+ setup_to_paths[key].append(path)
+ if key not in setup_to_steps:
+ setup_to_steps[key] = steps
+
+ fixtures = []
+ for key, paths in setup_to_paths.items():
+ steps = setup_to_steps[key]
+ setup = steps_to_setup(steps)
+ fixtures.append(FixtureSpec(paths=paths, setup=setup))
+
+ return fixtures
+
+
+def generate_fixture(client: httpx.Client, spec: FixtureSpec, pbar: tqdm) -> bool:
+ primary_path = spec.paths[0]
+ relative_path = primary_path.relative_to(FIXTURES_DIR)
+
+ try:
+ for method, params in spec.setup:
+ response = api(client, method, params)
+ if isinstance(response, dict) and "error" in response:
+ error_msg = response["error"].get("message", str(response["error"]))
+ pbar.write(f" Error: {relative_path} - {error_msg}")
+ return False
+
+ primary_path.parent.mkdir(parents=True, exist_ok=True)
+ response = api(client, "save", {"path": str(primary_path)})
+ if isinstance(response, dict) and "error" in response:
+ error_msg = response["error"].get("message", str(response["error"]))
+ pbar.write(f" Error: {relative_path} - {error_msg}")
+ return False
+
+ for dest_path in spec.paths[1:]:
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
+ dest_path.write_bytes(primary_path.read_bytes())
+
+ return True
+
+ except Exception as e:
+ pbar.write(f" Error: {relative_path} failed: {e}")
+ return False
+
+
+def main() -> int:
+ print("BalatroBot Fixture Generator")
+ print(f"Connecting to {HOST}:{PORT}\n")
+
+ json_data = load_fixtures_json()
+ fixtures = aggregate_fixtures(json_data)
+ print(f"Loaded {len(fixtures)} unique fixture configurations\n")
+
+ try:
+ with httpx.Client(
+ base_url=f"http://{HOST}:{PORT}",
+ timeout=httpx.Timeout(60.0, read=10.0),
+ ) as client:
+ success = 0
+ failed = 0
+
+ with tqdm(
+ total=len(fixtures), desc="Generating fixtures", unit="fixture"
+ ) as pbar:
+ for spec in fixtures:
+ if generate_fixture(client, spec, pbar):
+ success += 1
+ else:
+ failed += 1
+ pbar.update(1)
+
+ api(client, "menu", {})
+
+ corrupted_path = FIXTURES_DIR / "load" / "corrupted.jkr"
+ corrupt_file(corrupted_path)
+ success += 1
+
+ print(f"\nSummary: {success} generated, {failed} failed")
+ return 1 if failed > 0 else 0
+
+ except httpx.ConnectError:
+ 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}")
+ return 1
+ except Exception as e:
+ print(f"Error: {e}")
+ return 1
+
+
+if __name__ == "__main__":
+ exit(main())
diff --git a/tests/lua/conftest.py b/tests/lua/conftest.py
index a2742f6..98416f3 100644
--- a/tests/lua/conftest.py
+++ b/tests/lua/conftest.py
@@ -1,165 +1,396 @@
"""Lua API test-specific configuration and fixtures."""
import json
-import platform
-import shutil
-import socket
+import tempfile
+import uuid
from pathlib import Path
from typing import Any, Generator
+import httpx
import pytest
-# Connection settings
-HOST = "127.0.0.1"
-TIMEOUT: float = 60.0 # timeout for socket operations in seconds
-BUFFER_SIZE: int = 65536 # 64KB buffer for TCP messages
+# ============================================================================
+# Constants
+# ============================================================================
+
+HOST: str = "127.0.0.1" # Default host for Balatro server
+PORT: int = 12346 # Default port for Balatro server
+CONNECTION_TIMEOUT: float = 60.0 # Connection timeout in seconds
+REQUEST_TIMEOUT: float = 5.0 # Default per-request timeout in seconds
+
+# JSON-RPC 2.0 request ID counter
+_request_id_counter: int = 0
+
+
+@pytest.fixture(scope="session")
+def host() -> str:
+ """Return the default Balatro server host."""
+ return HOST
@pytest.fixture
-def tcp_client(port: int) -> Generator[socket.socket, None, None]:
- """Create and clean up a TCP client socket.
+def client(host: str, port: int) -> 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.
Yields:
- Configured TCP socket for testing.
+ An httpx.Client for communicating with the game.
"""
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(TIMEOUT)
- # Set socket receive buffer size
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE)
- sock.connect((HOST, port))
- yield sock
+ with httpx.Client(
+ base_url=f"http://{host}:{port}",
+ timeout=httpx.Timeout(CONNECTION_TIMEOUT, read=REQUEST_TIMEOUT),
+ ) as http_client:
+ yield http_client
-def send_api_message(sock: socket.socket, name: str, arguments: dict) -> None:
- """Send a properly formatted JSON API message.
+@pytest.fixture(scope="session")
+def port() -> int:
+ """Return the default Balatro server port."""
+ return PORT
+
+
+# ============================================================================
+# Helper Functions
+# ============================================================================
+
+
+def api(
+ client: httpx.Client,
+ method: str,
+ params: dict = {},
+ timeout: float = REQUEST_TIMEOUT,
+) -> dict[str, Any]:
+ """Send a JSON-RPC 2.0 API call to the Balatro game and get the response.
Args:
- sock: Socket to send through.
- name: Function name to call.
- arguments: Arguments dictionary for the function.
+ client: The HTTP client connected to the game.
+ method: The name of the API method to call.
+ params: Dictionary of parameters to pass to the API method (default: {}).
+ timeout: Request timeout in seconds (default: 5.0).
+
+ Returns:
+ The raw JSON-RPC 2.0 response with either 'result' or 'error' field.
+ """
+ global _request_id_counter
+ _request_id_counter += 1
+
+ payload = {
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ "id": _request_id_counter,
+ }
+
+ response = client.post("/", json=payload, timeout=timeout)
+ response.raise_for_status()
+ return response.json()
+
+
+def send_request(
+ client: httpx.Client,
+ method: str,
+ params: dict[str, Any],
+ request_id: int | str | None = None,
+ timeout: float = REQUEST_TIMEOUT,
+) -> httpx.Response:
+ """Send a JSON-RPC 2.0 request to the server.
+
+ Args:
+ client: The HTTP client connected to the game.
+ method: The name of the method to call.
+ params: Dictionary of parameters to pass to the method.
+ request_id: Optional request ID (auto-increments if not provided).
+ timeout: Request timeout in seconds (default: 5.0).
+
+ Returns:
+ The HTTP response object.
"""
- message = {"name": name, "arguments": arguments}
- sock.send(json.dumps(message).encode() + b"\n")
+ global _request_id_counter
+ if request_id is None:
+ _request_id_counter += 1
+ request_id = _request_id_counter
+
+ request = {
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ "id": request_id,
+ }
+ return client.post("/", json=request, timeout=timeout)
-def receive_api_message(sock: socket.socket) -> dict[str, Any]:
- """Receive a properly formatted JSON API message from the socket.
+
+def get_fixture_path(endpoint: str, fixture_name: str) -> Path:
+ """Get path to a test fixture file.
Args:
- sock: Socket to receive from.
+ endpoint: The endpoint directory (e.g., "save", "load").
+ fixture_name: Name of the fixture file (e.g., "start.jkr").
Returns:
- Received message as a dictionary.
+ Path to the fixture file in tests/fixtures//.
"""
- data = sock.recv(BUFFER_SIZE)
- return json.loads(data.decode().strip())
+ fixtures_dir = Path(__file__).parent.parent / "fixtures"
+ return fixtures_dir / endpoint / f"{fixture_name}.jkr"
+
+def create_temp_save_path() -> Path:
+ """Create a temporary path for save files.
-def send_and_receive_api_message(
- sock: socket.socket, name: str, arguments: dict
+ Returns:
+ Path to a temporary .jkr file in the system temp directory.
+ """
+ temp_dir = Path(tempfile.gettempdir())
+ return temp_dir / f"balatrobot_test_{uuid.uuid4().hex[:8]}.jkr"
+
+
+def load_fixture(
+ client: httpx.Client,
+ endpoint: str,
+ fixture_name: str,
+ cache: bool = True,
) -> dict[str, Any]:
- """Send a properly formatted JSON API message and receive the response.
+ """Load a fixture file and return the resulting gamestate.
- Args:
- sock: Socket to send through.
- name: Function name to call.
- arguments: Arguments dictionary for the function.
+ This helper function consolidates the common pattern of:
+ 1. Loading a fixture file (or generating it if missing)
+ 2. Asserting the load succeeded
+ 3. Getting the current gamestate
- Returns:
- The game state after the message is sent and received.
+ If the fixture file doesn't exist or cache=False, it will be automatically
+ generated using the setup steps defined in fixtures.json.
"""
- send_api_message(sock, name, arguments)
- game_state = receive_api_message(sock)
- return game_state
+ fixture_path = get_fixture_path(endpoint, fixture_name)
+ # Generate fixture if it doesn't exist or cache=False
+ if not fixture_path.exists() or not cache:
+ fixtures_json_path = Path(__file__).parent.parent / "fixtures" / "fixtures.json"
+ with open(fixtures_json_path) as f:
+ fixtures_data = json.load(f)
-def assert_error_response(
- response,
- expected_error_text,
- expected_context_keys=None,
- expected_error_code=None,
-):
+ if endpoint not in fixtures_data:
+ raise KeyError(f"Endpoint '{endpoint}' not found in fixtures.json")
+ if fixture_name not in fixtures_data[endpoint]:
+ raise KeyError(
+ f"Fixture key '{fixture_name}' not found in fixtures.json['{endpoint}']"
+ )
+
+ setup_steps = fixtures_data[endpoint][fixture_name]
+
+ # Execute each setup step
+ for step in setup_steps:
+ step_method = step["method"]
+ step_params = step.get("params", {})
+ response = api(client, step_method, step_params)
+
+ # Check for errors during generation
+ if "error" in response:
+ error_msg = response["error"]["message"]
+ raise AssertionError(
+ f"Fixture generation failed at step {step_method}: {error_msg}"
+ )
+
+ # Save the fixture
+ fixture_path.parent.mkdir(parents=True, exist_ok=True)
+ save_response = api(client, "save", {"path": str(fixture_path)})
+ assert_path_response(save_response)
+
+ # Load the fixture
+ load_response = api(client, "load", {"path": str(fixture_path)})
+ assert_path_response(load_response)
+ gamestate_response = api(client, "gamestate", {})
+ return gamestate_response["result"]
+
+
+# ============================================================================
+# Assertion Helpers
+# ============================================================================
+
+
+def assert_health_response(response: dict[str, Any]) -> None:
+ """Assert response is a Response.Endpoint.Health.
+
+ Used by: health endpoint.
+
+ Args:
+ response: The raw JSON-RPC 2.0 response.
+
+ Raises:
+ AssertionError: If response is not a valid HealthResponse.
"""
- Helper function to assert the format and content of an error response.
+ assert "result" in response, f"Expected 'result' in response, got: {response}"
+ assert "error" not in response, f"Unexpected error: {response.get('error')}"
+ result = response["result"]
+ assert "status" in result, f"HealthResponse missing 'status': {result}"
+ assert result["status"] == "ok", f"HealthResponse status not 'ok': {result}"
+
+
+def assert_path_response(
+ response: dict[str, Any],
+ expected_path: str | None = None,
+) -> str:
+ """Assert response is a Response.Endpoint.Path and return the path.
+
+ Used by: save, load endpoints.
Args:
- response (dict): The response dictionary to validate. Must contain at least
- the keys "error", "state", and "error_code".
- expected_error_text (str): The expected error message text to check within
- the "error" field of the response.
- expected_context_keys (list, optional): A list of keys expected to be present
- in the "context" field of the response, if the "context" field exists.
- expected_error_code (str, optional): The expected error code to check within
- the "error_code" field of the response.
+ response: The raw JSON-RPC 2.0 response.
+ expected_path: Optional expected path to verify.
+
+ Returns:
+ The path from the response.
Raises:
- AssertionError: If the response does not match the expected format or content.
+ AssertionError: If response is not a valid PathResponse.
"""
- assert isinstance(response, dict)
- assert "error" in response
- assert "state" in response
- assert "error_code" in response
- assert expected_error_text in response["error"]
- if expected_error_code:
- assert response["error_code"] == expected_error_code
- if expected_context_keys:
- assert "context" in response
- for key in expected_context_keys:
- assert key in response["context"]
+ assert "result" in response, f"Expected 'result' in response, got: {response}"
+ assert "error" not in response, f"Unexpected error: {response.get('error')}"
+ result = response["result"]
+ assert "success" in result, f"PathResponse missing 'success': {result}"
+ assert result["success"] is True, f"PathResponse success is not True: {result}"
+ assert "path" in result, f"PathResponse missing 'path': {result}"
+ assert isinstance(result["path"], str), (
+ f"PathResponse 'path' not a string: {result}"
+ )
+
+ if expected_path is not None:
+ assert result["path"] == expected_path, (
+ f"Expected path '{expected_path}', got '{result['path']}'"
+ )
+ return result["path"]
-def prepare_checkpoint(sock: socket.socket, checkpoint_path: Path) -> dict[str, Any]:
- """Prepare a checkpoint file for loading and load it into the game.
- This function copies a checkpoint file to Love2D's save directory and loads it
- directly without requiring a game restart.
+def assert_gamestate_response(
+ response: dict[str, Any],
+ **expected_fields: Any,
+) -> dict[str, Any]:
+ """Assert response is a Response.Endpoint.GameState and return the gamestate.
+
+ Used by: gamestate, menu, start, set, buy, sell, play, discard, select, etc.
Args:
- sock: Socket connection to the game.
- checkpoint_path: Path to the checkpoint .jkr file to load.
+ response: The raw JSON-RPC 2.0 response.
+ **expected_fields: Optional field values to verify (e.g., state="SHOP", money=100).
Returns:
- Game state after loading the checkpoint.
+ The gamestate from the response.
Raises:
- FileNotFoundError: If checkpoint file doesn't exist.
- RuntimeError: If loading the checkpoint fails.
+ AssertionError: If response is not a valid GameStateResponse.
"""
- if not checkpoint_path.exists():
- raise FileNotFoundError(f"Checkpoint file not found: {checkpoint_path}")
+ assert "result" in response, f"Expected 'result' in response, got: {response}"
+ assert "error" not in response, f"Unexpected error: {response.get('error')}"
+ result = response["result"]
+
+ # Verify required GameState field
+ assert "state" in result, f"GameStateResponse missing 'state': {result}"
+ assert isinstance(result["state"], str), (
+ f"GameStateResponse 'state' not a string: {result}"
+ )
+
+ # Verify any expected fields
+ for field, expected_value in expected_fields.items():
+ assert field in result, f"GameStateResponse missing '{field}': {result}"
+ assert result[field] == expected_value, (
+ f"GameStateResponse '{field}': expected {expected_value!r}, got {result[field]!r}"
+ )
+
+ return result
+
- # First, get the save directory from the game
- game_state = send_and_receive_api_message(sock, "get_save_info", {})
+def assert_test_response(
+ response: dict[str, Any],
+ expected_received_args: dict[str, Any] | None = None,
+ expected_state_validated: bool | None = None,
+) -> dict[str, Any]:
+ """Assert response is a Response.Endpoint.Test and return the result.
+
+ Used by: test_validation, test_endpoint, test_state, test_echo endpoints.
- # Determine the Love2D save directory
- # On Linux with Steam, convert Windows paths
+ Args:
+ response: The raw JSON-RPC 2.0 response.
+ expected_received_args: Optional expected received_args to verify.
+ expected_state_validated: Optional expected state_validated to verify.
+
+ Returns:
+ The test result from the response.
- save_dir_str = game_state["save_directory"]
- if platform.system() == "Linux" and save_dir_str.startswith("C:"):
- # Replace C: with Linux Steam Proton prefix
- linux_prefix = (
- Path.home() / ".steam/steam/steamapps/compatdata/2379780/pfx/drive_c"
+ Raises:
+ AssertionError: If response is not a valid TestResponse.
+ """
+ assert "result" in response, f"Expected 'result' in response, got: {response}"
+ assert "error" not in response, f"Unexpected error: {response.get('error')}"
+ result = response["result"]
+ assert "success" in result, f"TestResponse missing 'success': {result}"
+ assert result["success"] is True, f"TestResponse success is not True: {result}"
+
+ if expected_received_args is not None:
+ assert "received_args" in result, (
+ f"TestResponse missing 'received_args': {result}"
+ )
+ assert result["received_args"] == expected_received_args, (
+ f"TestResponse received_args: expected {expected_received_args}, got {result['received_args']}"
+ )
+
+ if expected_state_validated is not None:
+ assert "state_validated" in result, (
+ f"TestResponse missing 'state_validated': {result}"
)
- save_dir_str = str(linux_prefix) + "/" + save_dir_str[3:]
+ assert result["state_validated"] == expected_state_validated, (
+ f"TestResponse state_validated: expected {expected_state_validated}, got {result['state_validated']}"
+ )
+
+ return result
+
+
+def assert_error_response(
+ response: dict[str, Any],
+ expected_error_name: str | None = None,
+ expected_message_contains: str | None = None,
+) -> dict[str, Any]:
+ """Assert response is a Response.Server.Error and return the error data.
- save_dir = Path(save_dir_str)
+ Args:
+ response: The raw JSON-RPC 2.0 response.
+ expected_error_name: Optional expected error name (BAD_REQUEST, INVALID_STATE, etc.).
+ expected_message_contains: Optional substring to check in error message (case-insensitive).
- # Copy checkpoint to a test profile in Love2D save directory
- test_profile = "test_checkpoint"
- test_dir = save_dir / test_profile
- test_dir.mkdir(parents=True, exist_ok=True)
+ Returns:
+ The error data dict with 'name' field.
- dest_path = test_dir / "save.jkr"
- shutil.copy2(checkpoint_path, dest_path)
+ Raises:
+ AssertionError: If response is not a valid ErrorResponse.
+ """
+ assert "error" in response, f"Expected 'error' in response, got: {response}"
+ assert "result" not in response, (
+ f"Unexpected 'result' in error response: {response}"
+ )
- # Load the save using the new load_save API function
- love2d_path = f"{test_profile}/save.jkr"
- game_state = send_and_receive_api_message(
- sock, "load_save", {"save_path": love2d_path}
+ error = response["error"]
+ assert "message" in error, f"ErrorResponse missing 'message': {error}"
+ assert "data" in error, f"ErrorResponse missing 'data': {error}"
+ assert "name" in error["data"], f"ErrorResponse data missing 'name': {error}"
+ assert isinstance(error["message"], str), (
+ f"ErrorResponse 'message' not a string: {error}"
+ )
+ assert isinstance(error["data"]["name"], str), (
+ f"ErrorResponse 'name' not a string: {error}"
)
- # Check for errors
- if "error" in game_state:
- raise RuntimeError(f"Failed to load checkpoint: {game_state['error']}")
+ if expected_error_name is not None:
+ actual_name = error["data"]["name"]
+ assert actual_name == expected_error_name, (
+ f"Expected error name '{expected_error_name}', got '{actual_name}'"
+ )
+
+ if expected_message_contains is not None:
+ actual_message = error["message"]
+ assert expected_message_contains.lower() in actual_message.lower(), (
+ f"Expected message to contain '{expected_message_contains}', got '{actual_message}'"
+ )
- return game_state
+ return error["data"]
diff --git a/tests/lua/core/__init__.py b/tests/lua/core/__init__.py
new file mode 100644
index 0000000..9d808ac
--- /dev/null
+++ b/tests/lua/core/__init__.py
@@ -0,0 +1,2 @@
+# tests/lua/core/__init__.py
+# Core module tests
diff --git a/tests/lua/core/test_dispatcher.py b/tests/lua/core/test_dispatcher.py
new file mode 100644
index 0000000..267d584
--- /dev/null
+++ b/tests/lua/core/test_dispatcher.py
@@ -0,0 +1,311 @@
+"""
+Integration tests for BB_DISPATCHER request routing and validation (JSON-RPC 2.0).
+
+Test classes are organized by validation tier:
+- TestDispatcherProtocolValidation: TIER 1 - Protocol structure validation
+- TestDispatcherSchemaValidation: TIER 2 - Schema/argument validation
+- TestDispatcherStateValidation: TIER 3 - Game state validation
+- TestDispatcherExecution: TIER 4 - Endpoint execution and error handling
+- TestDispatcherEndpointRegistry: Endpoint registration and discovery
+"""
+
+import httpx
+
+from tests.lua.conftest import api
+
+# Request ID counter for malformed request tests only
+_test_request_id = 0
+
+
+class TestDispatcherProtocolValidation:
+ """Tests for TIER 1: Protocol Validation.
+
+ Tests verify that dispatcher correctly validates:
+ - Request has 'method' field (string)
+ - Request has 'params' field (optional, defaults to {})
+ - Endpoint exists in registry
+ """
+
+ def test_missing_name_field(self, client: httpx.Client) -> None:
+ """Test that requests without 'method' field are rejected."""
+ global _test_request_id
+ _test_request_id += 1
+ # Send JSON-RPC request missing 'method' field
+ response = client.post(
+ "/",
+ json={"jsonrpc": "2.0", "params": {}, "id": _test_request_id},
+ )
+ parsed = response.json()
+
+ assert "error" in parsed
+ assert "message" in parsed["error"]
+ assert "data" in parsed["error"]
+ assert "name" in parsed["error"]["data"]
+ assert parsed["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "method" in parsed["error"]["message"].lower()
+
+ def test_invalid_name_type(self, client: httpx.Client) -> None:
+ """Test that 'method' field must be a string."""
+ global _test_request_id
+ _test_request_id += 1
+ # Send JSON-RPC request with 'method' as integer
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": 123,
+ "params": {},
+ "id": _test_request_id,
+ },
+ )
+ parsed = response.json()
+
+ assert "error" in parsed
+ assert parsed["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_missing_arguments_field(self, client: httpx.Client) -> None:
+ """Test that requests without 'params' field succeed (params is optional in JSON-RPC 2.0)."""
+ global _test_request_id
+ _test_request_id += 1
+ # Send JSON-RPC request without 'params' field
+ response = client.post(
+ "/",
+ json={"jsonrpc": "2.0", "method": "health", "id": _test_request_id},
+ )
+ parsed = response.json()
+
+ # In JSON-RPC 2.0, params is optional - should succeed for health
+ assert "result" in parsed
+ assert "status" in parsed["result"]
+ assert parsed["result"]["status"] == "ok"
+
+ def test_unknown_endpoint(self, client: httpx.Client) -> None:
+ """Test that unknown endpoints are rejected."""
+ response = api(client, "nonexistent_endpoint", {})
+
+ assert "error" in response
+ assert response["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "nonexistent_endpoint" in response["error"]["message"]
+
+ def test_valid_health_endpoint_request(self, client: httpx.Client) -> None:
+ """Test that valid requests to health endpoint succeed."""
+ response = api(client, "health", {})
+
+ # Health endpoint should return success
+ assert "result" in response
+ assert "status" in response["result"]
+ assert response["result"]["status"] == "ok"
+
+
+class TestDispatcherSchemaValidation:
+ """Tests for TIER 2: Schema Validation.
+
+ Tests verify that dispatcher correctly validates arguments against
+ endpoint schemas using the Validator module.
+ """
+
+ def test_missing_required_field(self, client: httpx.Client) -> None:
+ """Test that missing required fields are rejected."""
+ # test_endpoint requires 'required_string' and 'required_integer'
+ response = api(
+ client,
+ "test_endpoint",
+ {
+ "required_integer": 50,
+ "required_enum": "option_a",
+ # Missing 'required_string'
+ },
+ )
+
+ assert "error" in response
+ assert response["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "required_string" in response["error"]["message"].lower()
+
+ def test_invalid_type_string_instead_of_integer(self, client: httpx.Client) -> None:
+ """Test that type validation rejects wrong types."""
+ response = api(
+ client,
+ "test_endpoint",
+ {
+ "required_string": "valid_string",
+ "required_integer": "not_an_integer", # Should be integer
+ "required_enum": "option_a",
+ },
+ )
+
+ assert "error" in response
+ assert response["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "required_integer" in response["error"]["message"].lower()
+
+ def test_array_item_type_validation(self, client: httpx.Client) -> None:
+ """Test that array items are validated for correct type."""
+ response = api(
+ client,
+ "test_endpoint",
+ {
+ "required_string": "test",
+ "required_integer": 50,
+ "optional_array_integers": [
+ 1,
+ 2,
+ "not_integer",
+ 4,
+ ], # Should be integers
+ },
+ )
+
+ assert "error" in response
+ assert response["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_valid_request_with_all_fields(self, client: httpx.Client) -> None:
+ """Test that valid requests with multiple fields pass validation."""
+ response = api(
+ client,
+ "test_endpoint",
+ {
+ "required_string": "test",
+ "required_integer": 50,
+ "optional_string": "optional",
+ "optional_integer": 42,
+ "optional_array_integers": [1, 2, 3],
+ },
+ )
+
+ # Should succeed and echo back
+ assert "result" in response
+ assert "success" in response["result"]
+ assert response["result"]["success"] is True
+ assert "received_args" in response["result"]
+
+ def test_valid_request_with_only_required_fields(
+ self, client: httpx.Client
+ ) -> None:
+ """Test that valid requests with only required fields pass validation."""
+ response = api(
+ client,
+ "test_endpoint",
+ {
+ "required_string": "test",
+ "required_integer": 1,
+ "required_enum": "option_c",
+ },
+ )
+
+ assert "result" in response
+ assert "success" in response["result"]
+ assert response["result"]["success"] is True
+
+
+class TestDispatcherStateValidation:
+ """Tests for TIER 3: Game State Validation.
+
+ Tests verify that dispatcher enforces endpoint state requirements.
+ Note: These tests may pass or fail depending on current game state.
+ """
+
+ def test_state_validation_enforcement(self, client: httpx.Client) -> None:
+ """Test that endpoints with requires_state are validated."""
+ # test_state_endpoint requires SPLASH or MENU state
+ response = api(client, "test_state_endpoint", {})
+
+ # Response depends on current game state
+ # Either succeeds if in correct state, or fails with INVALID_STATE
+ if "error" in response:
+ assert response["error"]["data"]["name"] == "INVALID_STATE"
+ assert "requires" in response["error"]["message"].lower()
+ else:
+ assert "result" in response
+ assert "success" in response["result"]
+ assert response["result"]["state_validated"] is True
+
+
+class TestDispatcherExecution:
+ """Tests for TIER 4: Endpoint Execution and Error Handling.
+
+ Tests verify that dispatcher correctly executes endpoints and
+ handles runtime errors with appropriate error codes.
+ """
+
+ def test_successful_endpoint_execution(self, client: httpx.Client) -> None:
+ """Test that endpoints execute successfully with valid input."""
+ response = api(
+ client,
+ "test_endpoint",
+ {
+ "required_string": "test",
+ "required_integer": 42,
+ "required_enum": "option_a",
+ },
+ )
+
+ assert "result" in response
+ assert "success" in response["result"]
+ assert response["result"]["success"] is True
+ assert "received_args" in response["result"]
+ assert response["result"]["received_args"]["required_integer"] == 42
+
+ def test_execution_error_handling(self, client: httpx.Client) -> None:
+ """Test that runtime errors are caught and returned properly."""
+ response = api(client, "test_error_endpoint", {"error_type": "throw_error"})
+
+ assert "error" in response
+ assert response["error"]["data"]["name"] == "INTERNAL_ERROR"
+ assert "Intentional test error" in response["error"]["message"]
+
+ def test_execution_error_no_categorization(self, client: httpx.Client) -> None:
+ """Test that all execution errors use INTERNAL_ERROR."""
+ response = api(client, "test_error_endpoint", {"error_type": "throw_error"})
+
+ # Should always be INTERNAL_ERROR (no categorization)
+ assert response["error"]["data"]["name"] == "INTERNAL_ERROR"
+
+ def test_execution_success_when_no_error(self, client: httpx.Client) -> None:
+ """Test that endpoints can execute successfully."""
+ response = api(client, "test_error_endpoint", {"error_type": "success"})
+
+ assert "result" in response
+ assert "success" in response["result"]
+ assert response["result"]["success"] is True
+
+
+class TestDispatcherEndpointRegistry:
+ """Tests for endpoint registration and discovery."""
+
+ def test_health_endpoint_is_registered(self, client: httpx.Client) -> None:
+ """Test that the health endpoint is properly registered."""
+ response = api(client, "health", {})
+
+ assert "result" in response
+ assert "status" in response["result"]
+ assert response["result"]["status"] == "ok"
+
+ def test_multiple_sequential_requests_to_same_endpoint(
+ self, client: httpx.Client
+ ) -> None:
+ """Test that multiple requests to the same endpoint work."""
+ for i in range(3):
+ response = api(client, "health", {})
+
+ assert "result" in response
+ assert "status" in response["result"]
+ assert response["result"]["status"] == "ok"
+
+ def test_requests_to_different_endpoints(self, client: httpx.Client) -> None:
+ """Test that requests can be routed to different endpoints."""
+ # Request to health endpoint
+ response1 = api(client, "health", {})
+ assert "result" in response1
+ assert "status" in response1["result"]
+
+ # Request to test_endpoint
+ response2 = api(
+ client,
+ "test_endpoint",
+ {
+ "required_string": "test",
+ "required_integer": 25,
+ "required_enum": "option_a",
+ },
+ )
+ assert "result" in response2
+ assert "success" in response2["result"]
diff --git a/tests/lua/core/test_server.py b/tests/lua/core/test_server.py
new file mode 100644
index 0000000..12cab4b
--- /dev/null
+++ b/tests/lua/core/test_server.py
@@ -0,0 +1,493 @@
+"""
+Integration tests for BB_SERVER HTTP communication (JSON-RPC 2.0).
+
+Test classes are organized by functionality:
+- TestHTTPServerInit: Server initialization and port binding
+- TestHTTPServerRouting: HTTP routing (POST to "/" only, rpc.discover)
+- TestHTTPServerJSONRPC: JSON-RPC 2.0 protocol enforcement
+- TestHTTPServerRequestID: Request ID validation
+- TestHTTPServerErrors: HTTP error responses
+"""
+
+import errno
+import socket
+
+import httpx
+import pytest
+
+
+class TestHTTPServerInit:
+ """Tests for HTTP server initialization and port binding."""
+
+ def test_server_binds_to_configured_port(self, port: int) -> 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}"
+
+ def test_port_is_exclusively_bound(self, port: int) -> 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))
+ assert exc_info.value.errno == errno.EADDRINUSE
+
+ def test_server_responds_to_http(self, client: httpx.Client) -> None:
+ """Test that server responds to HTTP requests."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "result" in data
+ assert data["result"]["status"] == "ok"
+
+
+class TestHTTPServerRouting:
+ """Tests for HTTP request routing."""
+
+ def test_post_endpoint(self, client: httpx.Client) -> None:
+ """Test POST accepts JSON-RPC requests."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "jsonrpc" in data
+ assert data["jsonrpc"] == "2.0"
+
+ def test_rpc_discover_endpoint(self, client: httpx.Client) -> None:
+ """Test rpc.discover returns the OpenRPC spec."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "rpc.discover",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "result" in data
+ spec = data["result"]
+ assert "openrpc" in spec
+ assert spec["openrpc"] == "1.3.2"
+ assert "info" in spec
+ assert "methods" in spec
+
+ def test_get_returns_405(self, client: httpx.Client) -> None:
+ """Test that GET returns 405 Method Not Allowed."""
+ response = client.get("/")
+ assert response.status_code == 405
+ data = response.json()
+ assert "error" in data
+ assert "method not allowed" in data["error"]["message"].lower()
+
+ def test_put_returns_405(self, client: httpx.Client) -> None:
+ """Test that PUT returns 405 Method Not Allowed."""
+ response = client.put("/", json={})
+ assert response.status_code == 405
+ data = response.json()
+ assert "error" in data
+ assert "method not allowed" in data["error"]["message"].lower()
+
+ def test_options_returns_405(self, client: httpx.Client) -> None:
+ """Test that OPTIONS returns 405 Method Not Allowed."""
+ response = client.options("/")
+ assert response.status_code == 405
+ data = response.json()
+ assert "error" in data
+ assert "method not allowed" in data["error"]["message"].lower()
+
+ def test_post_to_non_root_returns_404(self, client: httpx.Client) -> None:
+ """Test that POST to paths other than '/' returns 404."""
+ response = client.post(
+ "/api/health",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 404
+ data = response.json()
+ assert "error" in data
+ assert "not found" in data["error"]["message"].lower()
+
+
+class TestHTTPServerJSONRPC:
+ """Tests for JSON-RPC 2.0 protocol enforcement over HTTP."""
+
+ def test_invalid_json_body(self, client: httpx.Client) -> None:
+ """Test that invalid JSON body returns JSON-RPC error."""
+ response = client.post(
+ "/",
+ content=b"{invalid json}",
+ headers={"Content-Type": "application/json"},
+ )
+ # HTTP 200 OK but JSON-RPC error in body
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "invalid json" in data["error"]["message"].lower()
+
+ def test_missing_jsonrpc_version(self, client: httpx.Client) -> None:
+ """Test that missing jsonrpc version returns error."""
+ response = client.post(
+ "/",
+ json={
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_wrong_jsonrpc_version(self, client: httpx.Client) -> None:
+ """Test that wrong jsonrpc version returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "1.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "2.0" in data["error"]["message"]
+
+ def test_response_includes_request_id(self, client: httpx.Client) -> None:
+ """Test that response includes the request ID."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 42,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == 42
+
+ def test_string_request_id(self, client: httpx.Client) -> None:
+ """Test that string request IDs are preserved."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": "my-request-id",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == "my-request-id"
+
+
+class TestHTTPServerRequestID:
+ """Tests for JSON-RPC 2.0 request ID validation."""
+
+ def test_missing_id_returns_error(self, client: httpx.Client) -> None:
+ """Test that missing 'id' field returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "id" in data["error"]["message"].lower()
+ # id is null or omitted when request had no id
+ assert data.get("id") is None
+
+ def test_null_id_returns_error(self, client: httpx.Client) -> None:
+ """Test that explicit null 'id' returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": None,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "id" in data["error"]["message"].lower()
+
+ def test_float_id_returns_error(self, client: httpx.Client) -> None:
+ """Test that floating-point 'id' returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1.5,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+ assert "integer" in data["error"]["message"].lower()
+
+ def test_boolean_id_returns_error(self, client: httpx.Client) -> None:
+ """Test that boolean 'id' returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": True,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_array_id_returns_error(self, client: httpx.Client) -> None:
+ """Test that array 'id' returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": [1, 2, 3],
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_object_id_returns_error(self, client: httpx.Client) -> None:
+ """Test that object 'id' returns error."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": {"key": "value"},
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_zero_id_is_valid(self, client: httpx.Client) -> None:
+ """Test that zero is a valid integer ID."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 0,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "result" in data
+ assert data["id"] == 0
+
+ def test_negative_id_is_valid(self, client: httpx.Client) -> None:
+ """Test that negative integers are valid IDs."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": -42,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "result" in data
+ assert data["id"] == -42
+
+ def test_empty_string_id_is_valid(self, client: httpx.Client) -> None:
+ """Test that empty string is a valid ID."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": "",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "result" in data
+ assert data["id"] == ""
+
+
+class TestHTTPServerErrors:
+ """Tests for HTTP error responses."""
+
+ def test_empty_body_returns_error(self, client: httpx.Client) -> None:
+ """Test that empty request body returns error."""
+ response = client.post(
+ "/",
+ content=b"",
+ headers={"Content-Type": "application/json"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_json_array_rejected(self, client: httpx.Client) -> None:
+ """Test that JSON array body is rejected (must be object)."""
+ response = client.post(
+ "/",
+ json=["array", "of", "values"],
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_json_string_rejected(self, client: httpx.Client) -> None:
+ """Test that JSON string body is rejected (must be object)."""
+ response = client.post(
+ "/",
+ content=b'"just a string"',
+ headers={"Content-Type": "application/json"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "error" in data
+ assert data["error"]["data"]["name"] == "BAD_REQUEST"
+
+ def test_connection_close_header(self, client: httpx.Client) -> None:
+ """Test that responses include Connection: close header."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ assert response.headers.get("Connection", "").lower() == "close"
+
+ def test_content_type_is_json(self, client: httpx.Client) -> None:
+ """Test that responses have application/json content type."""
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response.status_code == 200
+ assert "application/json" in response.headers["Content-Type"]
+
+
+class TestHTTPServerSequentialRequests:
+ """Tests for sequential HTTP request handling."""
+
+ def test_multiple_sequential_requests(self, client: httpx.Client) -> None:
+ """Test handling multiple sequential requests."""
+ for i in range(5):
+ response = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": i,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "result" in data
+ assert data["id"] == i
+
+ def test_different_endpoints_sequentially(self, client: httpx.Client) -> None:
+ """Test accessing different endpoints sequentially."""
+ # POST - health
+ response1 = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 1,
+ },
+ )
+ assert response1.status_code == 200
+
+ # POST - rpc.discover
+ response2 = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "rpc.discover",
+ "params": {},
+ "id": 2,
+ },
+ )
+ assert response2.status_code == 200
+ assert "result" in response2.json()
+
+ # OPTIONS (now returns 405)
+ response3 = client.options("/")
+ assert response3.status_code == 405
+
+ # POST again
+ response4 = client.post(
+ "/",
+ json={
+ "jsonrpc": "2.0",
+ "method": "health",
+ "params": {},
+ "id": 3,
+ },
+ )
+ assert response4.status_code == 200
diff --git a/tests/lua/core/test_validator.py b/tests/lua/core/test_validator.py
new file mode 100644
index 0000000..2143994
--- /dev/null
+++ b/tests/lua/core/test_validator.py
@@ -0,0 +1,437 @@
+# tests/lua/core/test_validator.py
+# Comprehensive tests for src/lua/core/validator.lua
+#
+# Tests validation scenarios through the dispatcher using the test_validation endpoint:
+# - Type validation (string, integer, boolean, array, table)
+# - Required field validation
+# - Array item type validation (integer arrays only)
+# - Error codes and messages
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_test_response,
+)
+
+# ============================================================================
+# Test: Type Validation
+# ============================================================================
+
+
+class TestTypeValidation:
+ """Test type validation for all supported types."""
+
+ def test_valid_string_type(self, client: httpx.Client) -> None:
+ """Test that valid string type passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "string_field": "hello",
+ },
+ )
+ assert_test_response(response)
+
+ def test_invalid_string_type(self, client: httpx.Client) -> None:
+ """Test that invalid string type fails validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "string_field": 123, # Should be string
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "string_field",
+ )
+
+ def test_valid_integer_type(self, client: httpx.Client) -> None:
+ """Test that valid integer type passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "integer_field": 42,
+ },
+ )
+ assert_test_response(response)
+
+ def test_invalid_integer_type_float(self, client: httpx.Client) -> None:
+ """Test that float fails integer validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "integer_field": 42.5, # Should be integer
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "integer_field",
+ )
+
+ def test_invalid_integer_type_string(self, client: httpx.Client) -> None:
+ """Test that string fails integer validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "integer_field": "42",
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "integer_field",
+ )
+
+ def test_valid_array_type(self, client: httpx.Client) -> None:
+ """Test that valid array type passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_field": [1, 2, 3],
+ },
+ )
+ assert_test_response(response)
+
+ def test_invalid_array_type_not_sequential(self, client: httpx.Client) -> None:
+ """Test that non-sequential table fails array validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_field": {"key": "value"}, # Not an array
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "array_field",
+ )
+
+ def test_invalid_array_type_string(self, client: httpx.Client) -> None:
+ """Test that string fails array validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_field": "not an array",
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "array_field",
+ )
+
+ def test_valid_boolean_type_true(self, client: httpx.Client) -> None:
+ """Test that boolean true passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "boolean_field": True,
+ },
+ )
+ assert_test_response(response)
+
+ def test_valid_boolean_type_false(self, client: httpx.Client) -> None:
+ """Test that boolean false passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "boolean_field": False,
+ },
+ )
+ assert_test_response(response)
+
+ def test_invalid_boolean_type_string(self, client: httpx.Client) -> None:
+ """Test that string fails boolean validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "boolean_field": "true", # Should be boolean, not string
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "boolean_field",
+ )
+
+ def test_invalid_boolean_type_number(self, client: httpx.Client) -> None:
+ """Test that number fails boolean validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "boolean_field": 1, # Should be boolean, not number
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "boolean_field",
+ )
+
+ def test_valid_table_type(self, client: httpx.Client) -> None:
+ """Test that valid table (non-array) passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "table_field": {"key": "value", "nested": {"data": 123}},
+ },
+ )
+ assert_test_response(response)
+
+ def test_valid_table_type_empty(self, client: httpx.Client) -> None:
+ """Test that empty table passes validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "table_field": {},
+ },
+ )
+ assert_test_response(response)
+
+ def test_invalid_table_type_array(self, client: httpx.Client) -> None:
+ """Test that array fails table validation (arrays should use 'array' type)."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "table_field": [1, 2, 3], # Array not allowed for 'table' type
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "table_field",
+ )
+
+ def test_invalid_table_type_string(self, client: httpx.Client) -> None:
+ """Test that string fails table validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "table_field": "not a table",
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "table_field",
+ )
+
+
+# ============================================================================
+# Test: Required Field Validation
+# ============================================================================
+
+
+class TestRequiredFields:
+ """Test required field validation."""
+
+ def test_required_field_present(self, client: httpx.Client) -> None:
+ """Test that request with required field passes."""
+ response = api(
+ client,
+ "test_validation",
+ {"required_field": "present"},
+ )
+ assert_test_response(response)
+
+ def test_required_field_missing(self, client: httpx.Client) -> None:
+ """Test that request without required field fails."""
+ response = api(
+ client,
+ "test_validation",
+ {}, # Missing required_field
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "required_field",
+ )
+
+ def test_optional_field_missing(self, client: httpx.Client) -> None:
+ """Test that missing optional fields are allowed."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "present",
+ # All other fields are optional
+ },
+ )
+ assert_test_response(response)
+
+
+# ============================================================================
+# Test: Array Item Type Validation
+# ============================================================================
+
+
+class TestArrayItemTypes:
+ """Test array item type validation."""
+
+ def test_array_of_integers_valid(self, client: httpx.Client) -> None:
+ """Test that array of integers passes."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_of_integers": [1, 2, 3],
+ },
+ )
+ assert_test_response(response)
+
+ def test_array_of_integers_invalid_float(self, client: httpx.Client) -> None:
+ """Test that array with float items fails integer validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_of_integers": [1, 2.5, 3],
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "array_of_integers",
+ )
+
+ def test_array_of_integers_invalid_string(self, client: httpx.Client) -> None:
+ """Test that array with string items fails integer validation."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_of_integers": [1, "2", 3],
+ },
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "array_of_integers",
+ )
+
+
+# ============================================================================
+# Test: Fail-Fast Behavior
+# ============================================================================
+
+
+class TestFailFastBehavior:
+ """Test that validator fails fast on first error."""
+
+ def test_multiple_errors_returns_first(self, client: httpx.Client) -> None:
+ """Test that only the first error is returned when multiple errors exist."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ # Missing required_field (one error)
+ "string_field": 123, # Type error (another error)
+ "integer_field": "not an integer", # Type error (another error)
+ },
+ )
+ # Should get ONE error (fail-fast), not all errors
+ # The specific error depends on Lua table iteration order
+ assert_error_response(response)
+ # Verify it's one of the expected error codes
+ assert response["error"]["data"]["name"] in [
+ "BAD_REQUEST",
+ "BAD_REQUEST",
+ ]
+
+
+# ============================================================================
+# Test: Edge Cases
+# ============================================================================
+
+
+class TestEdgeCases:
+ """Test edge cases and boundary conditions."""
+
+ def test_empty_arguments_with_only_required_field(
+ self, client: httpx.Client
+ ) -> None:
+ """Test that arguments with only required field passes."""
+ response = api(
+ client,
+ "test_validation",
+ {"required_field": "only this"},
+ )
+ assert_test_response(response)
+
+ def test_all_fields_provided(self, client: httpx.Client) -> None:
+ """Test request with multiple valid fields."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "string_field": "hello",
+ "integer_field": 42,
+ "boolean_field": True,
+ "array_field": [1, 2, 3],
+ "table_field": {"key": "value"},
+ "array_of_integers": [4, 5, 6],
+ },
+ )
+ assert_test_response(response)
+
+ def test_empty_array_when_allowed(self, client: httpx.Client) -> None:
+ """Test that empty array passes when no min constraint."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "test",
+ "array_field": [],
+ },
+ )
+ assert_test_response(response)
+
+ def test_empty_string_when_allowed(self, client: httpx.Client) -> None:
+ """Test that empty string passes when no min constraint."""
+ response = api(
+ client,
+ "test_validation",
+ {
+ "required_field": "", # Empty but present
+ },
+ )
+ assert_test_response(response)
diff --git a/tests/lua/endpoints/__init__.py b/tests/lua/endpoints/__init__.py
index e69de29..55ac672 100644
--- a/tests/lua/endpoints/__init__.py
+++ b/tests/lua/endpoints/__init__.py
@@ -0,0 +1,2 @@
+# tests/lua/endpoints/__init__.py
+# Endpoint tests
diff --git a/tests/lua/endpoints/checkpoints/basic_shop_setup.jkr b/tests/lua/endpoints/checkpoints/basic_shop_setup.jkr
deleted file mode 100644
index d232311..0000000
--- a/tests/lua/endpoints/checkpoints/basic_shop_setup.jkr
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:efe6abb43bdf5c30c7ef32948c6d55d95ca2497eaf35911c6fd85dd44d1451a1
-size 10024
diff --git a/tests/lua/endpoints/checkpoints/buy_cant_use.jkr b/tests/lua/endpoints/checkpoints/buy_cant_use.jkr
deleted file mode 100644
index 7e633ca..0000000
--- a/tests/lua/endpoints/checkpoints/buy_cant_use.jkr
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8efbeaf9f23a663ef5f3c81984e1e4c7aa917a2acdca84087ef45145e698a5c4
-size 11379
diff --git a/tests/lua/endpoints/checkpoints/plasma_deck.jkr b/tests/lua/endpoints/checkpoints/plasma_deck.jkr
deleted file mode 100644
index 1cf8cd0..0000000
--- a/tests/lua/endpoints/checkpoints/plasma_deck.jkr
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d70f422bf04d9f735dd914c143c8eeeea9df8ffafc6013886da0fe803f46da82
-size 9271
diff --git a/tests/lua/endpoints/test_add.py b/tests/lua/endpoints/test_add.py
new file mode 100644
index 0000000..66b5d23
--- /dev/null
+++ b/tests/lua/endpoints/test_add.py
@@ -0,0 +1,641 @@
+"""Tests for src/lua/endpoints/add.lua"""
+
+import httpx
+import pytest
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestAddEndpoint:
+ """Test basic add endpoint functionality."""
+
+ def test_add_joker(self, client: httpx.Client) -> None:
+ """Test adding a joker with valid key."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker"})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+
+ def test_add_consumable_tarot(self, client: httpx.Client) -> None:
+ """Test adding a tarot consumable with valid key."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["count"] == 0
+ response = api(client, "add", {"key": "c_fool"})
+ after = assert_gamestate_response(response)
+ assert after["consumables"]["count"] == 1
+ assert after["consumables"]["cards"][0]["key"] == "c_fool"
+
+ def test_add_consumable_planet(self, client: httpx.Client) -> None:
+ """Test adding a planet consumable with valid key."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["count"] == 0
+ response = api(client, "add", {"key": "c_mercury"})
+ after = assert_gamestate_response(response)
+ assert after["consumables"]["count"] == 1
+ assert after["consumables"]["cards"][0]["key"] == "c_mercury"
+
+ def test_add_consumable_spectral(self, client: httpx.Client) -> None:
+ """Test adding a spectral consumable with valid key."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["count"] == 0
+ response = api(client, "add", {"key": "c_familiar"})
+ after = assert_gamestate_response(response)
+ assert after["consumables"]["count"] == 1
+ assert after["consumables"]["cards"][0]["key"] == "c_familiar"
+
+ def test_add_voucher(self, client: httpx.Client) -> None:
+ """Test adding a voucher with valid key in SHOP state."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["vouchers"]["count"] == 0
+ response = api(client, "add", {"key": "v_overstock_norm"})
+ after = assert_gamestate_response(response)
+ assert after["vouchers"]["count"] == 1
+ assert after["vouchers"]["cards"][0]["key"] == "v_overstock_norm"
+
+ def test_add_playing_card(self, client: httpx.Client) -> None:
+ """Test adding a playing card with valid key."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A"})
+ after = assert_gamestate_response(response)
+ assert after["hand"]["count"] == 9
+ assert after["hand"]["cards"][8]["key"] == "H_A"
+
+ def test_add_no_key_provided(self, client: httpx.Client) -> None:
+ """Test add endpoint with no key parameter."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "add", {}),
+ "BAD_REQUEST",
+ "Missing required field 'key'",
+ )
+
+
+class TestAddEndpointValidation:
+ """Test add endpoint parameter validation."""
+
+ def test_invalid_key_type_number(self, client: httpx.Client) -> None:
+ """Test that add fails when key parameter is a number."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "add", {"key": 123}),
+ "BAD_REQUEST",
+ "Field 'key' must be of type string",
+ )
+
+ def test_invalid_key_unknown_format(self, client: httpx.Client) -> None:
+ """Test that add fails when key has unknown prefix format."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ 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)",
+ )
+
+ def test_invalid_key_known_format(self, client: httpx.Client) -> None:
+ """Test that add fails when key has known format."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "add", {"key": "j_NON_EXTING_JOKER"}),
+ "BAD_REQUEST",
+ "Failed to add card: j_NON_EXTING_JOKER",
+ )
+
+
+class TestAddEndpointStateRequirements:
+ """Test add endpoint state requirements."""
+
+ def test_add_from_BLIND_SELECT(self, client: httpx.Client) -> None:
+ """Test that add fails from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "add", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "add", {"key": "j_joker"}),
+ "INVALID_STATE",
+ "Method 'add' requires one of these states: SELECTING_HAND, SHOP, ROUND_EVAL",
+ )
+
+ def test_add_playing_card_from_SHOP(self, client: httpx.Client) -> None:
+ """Test that add playing card fails from SHOP state."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert_error_response(
+ api(client, "add", {"key": "H_A"}),
+ "INVALID_STATE",
+ "Playing cards can only be added in SELECTING_HAND state",
+ )
+
+ def test_add_voucher_card_from_SELECTING_HAND(self, client: httpx.Client) -> None:
+ """Test that add voucher card fails from SELECTING_HAND state."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "add", {"key": "v_overstock"}),
+ "INVALID_STATE",
+ "Vouchers can only be added in SHOP state",
+ )
+
+
+class TestAddEndpointSeal:
+ """Test seal parameter for add endpoint."""
+
+ @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(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "seal": seal})
+ after = assert_gamestate_response(response)
+ assert after["hand"]["count"] == 9
+ assert after["hand"]["cards"][8]["key"] == "H_A"
+ assert after["hand"]["cards"][8]["modifier"]["seal"] == seal
+
+ def test_add_playing_card_invalid_seal(self, client: httpx.Client) -> None:
+ """Test adding a playing card with invalid seal value."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "seal": "WHITE"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Invalid seal value. Expected: RED, BLUE, GOLD, or PURPLE",
+ )
+
+ @pytest.mark.parametrize("key", ["j_joker", "c_fool", "v_overstock_norm"])
+ def test_add_non_playing_card_with_seal_fails(
+ self, client: httpx.Client, key: str
+ ) -> None:
+ """Test that adding non-playing cards with seal parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ response = api(client, "add", {"key": key, "seal": "RED"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Seal can only be applied to playing cards",
+ )
+
+
+class TestAddEndpointEdition:
+ """Test edition parameter for add endpoint."""
+
+ @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"])
+ def test_add_joker_with_edition(self, client: httpx.Client, edition: str) -> None:
+ """Test adding a joker with various editions."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker", "edition": edition})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+ assert after["jokers"]["cards"][0]["modifier"]["edition"] == edition
+
+ @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "POLYCHROME", "NEGATIVE"])
+ def test_add_playing_card_with_edition(
+ self, client: httpx.Client, edition: str
+ ) -> None:
+ """Test adding a playing card with various editions."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "edition": edition})
+ after = assert_gamestate_response(response)
+ assert after["hand"]["count"] == 9
+ assert after["hand"]["cards"][8]["key"] == "H_A"
+ 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)."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 0
+ response = api(client, "add", {"key": "c_fool", "edition": "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"
+
+ @pytest.mark.parametrize("edition", ["HOLO", "FOIL", "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."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 0
+ response = api(client, "add", {"key": "c_fool", "edition": edition})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Consumables can only have NEGATIVE edition",
+ )
+
+ def test_add_voucher_with_edition_fails(self, client: httpx.Client) -> None:
+ """Test that adding a voucher with any edition fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["vouchers"]["count"] == 0
+ response = api(client, "add", {"key": "v_overstock_norm", "edition": "FOIL"})
+ assert_error_response(
+ response, "BAD_REQUEST", "Edition cannot be applied to vouchers"
+ )
+
+ def test_add_playing_card_invalid_edition(self, client: httpx.Client) -> None:
+ """Test adding a playing card with invalid edition value."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "edition": "WHITE"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Invalid edition value. Expected: HOLO, FOIL, POLYCHROME, or NEGATIVE",
+ )
+
+
+class TestAddEndpointEnhancement:
+ """Test enhancement parameter for add endpoint."""
+
+ @pytest.mark.parametrize(
+ "enhancement",
+ ["BONUS", "MULT", "WILD", "GLASS", "STEEL", "STONE", "GOLD", "LUCKY"],
+ )
+ def test_add_playing_card_with_enhancement(
+ self, client: httpx.Client, enhancement: str
+ ) -> None:
+ """Test adding a playing card with various enhancements."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "enhancement": enhancement})
+ after = assert_gamestate_response(response)
+ assert after["hand"]["count"] == 9
+ assert after["hand"]["cards"][8]["key"] == "H_A"
+ assert after["hand"]["cards"][8]["modifier"]["enhancement"] == enhancement
+
+ def test_add_playing_card_invalid_enhancement(self, client: httpx.Client) -> None:
+ """Test adding a playing card with invalid enhancement value."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "enhancement": "WHITE"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Invalid enhancement value. Expected: BONUS, MULT, WILD, GLASS, STEEL, STONE, GOLD, or LUCKY",
+ )
+
+ @pytest.mark.parametrize("key", ["j_joker", "c_fool", "v_overstock_norm"])
+ def test_add_non_playing_card_with_enhancement_fails(
+ self, client: httpx.Client, key: str
+ ) -> None:
+ """Test that adding non-playing cards with enhancement parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 0
+ response = api(client, "add", {"key": key, "enhancement": "BONUS"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Enhancement can only be applied to playing cards",
+ )
+
+
+class TestAddEndpointStickers:
+ """Test sticker parameters (eternal, perishable) for add endpoint."""
+
+ def test_add_joker_with_eternal(self, client: httpx.Client) -> None:
+ """Test adding an eternal joker."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker", "eternal": True})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+ assert after["jokers"]["cards"][0]["modifier"]["eternal"] is True
+
+ @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"])
+ def test_add_non_joker_with_eternal_fails(
+ self, client: httpx.Client, key: str
+ ) -> None:
+ """Test that adding non-joker cards with eternal parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 0
+ assert_error_response(
+ api(client, "add", {"key": key, "eternal": True}),
+ "BAD_REQUEST",
+ "Eternal can only be applied to jokers",
+ )
+
+ def test_add_playing_card_with_eternal_fails(self, client: httpx.Client) -> None:
+ """Test that adding a playing card with eternal parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ assert_error_response(
+ api(client, "add", {"key": "H_A", "eternal": True}),
+ "BAD_REQUEST",
+ "Eternal can only be applied to jokers",
+ )
+
+ @pytest.mark.parametrize("rounds", [1, 5, 10])
+ def test_add_joker_with_perishable(self, client: httpx.Client, rounds: int) -> None:
+ """Test adding a perishable joker with valid round values."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker", "perishable": rounds})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+ assert after["jokers"]["cards"][0]["modifier"]["perishable"] == rounds
+
+ def test_add_joker_with_eternal_and_perishable(self, client: httpx.Client) -> None:
+ """Test adding a joker with both eternal and perishable stickers."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(
+ client, "add", {"key": "j_joker", "eternal": True, "perishable": 5}
+ )
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+ assert after["jokers"]["cards"][0]["modifier"]["eternal"] is True
+ assert after["jokers"]["cards"][0]["modifier"]["perishable"] == 5
+
+ @pytest.mark.parametrize("invalid_value", [0, -1])
+ def test_add_joker_with_perishable_invalid_integer_fails(
+ self, client: httpx.Client, invalid_value: int
+ ) -> None:
+ """Test that invalid perishable values (zero, negative, float) are rejected."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker", "perishable": invalid_value})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Perishable must be a positive integer (>= 1)",
+ )
+
+ @pytest.mark.parametrize("invalid_value", [1.5, "NOT_INT_1"])
+ def test_add_joker_with_perishable_invalid_type_fails(
+ self, client: httpx.Client, invalid_value: float | str
+ ) -> None:
+ """Test that perishable with string value is rejected."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker", "perishable": invalid_value})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'perishable' must be an integer",
+ )
+
+ @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"])
+ def test_add_non_joker_with_perishable_fails(
+ self, client: httpx.Client, key: str
+ ) -> None:
+ """Test that adding non-joker cards with perishable parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ response = api(client, "add", {"key": key, "perishable": 5})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Perishable can only be applied to jokers",
+ )
+
+ def test_add_playing_card_with_perishable_fails(self, client: httpx.Client) -> None:
+ """Test that adding a playing card with perishable parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(client, "add", {"key": "H_A", "perishable": 5})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Perishable can only be applied to jokers",
+ )
+
+ def test_add_joker_with_rental(self, client: httpx.Client) -> None:
+ """Test adding a rental joker."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(client, "add", {"key": "j_joker", "rental": True})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+ assert after["jokers"]["cards"][0]["modifier"]["rental"] is True
+
+ @pytest.mark.parametrize("key", ["c_fool", "v_overstock_norm"])
+ def test_add_non_joker_with_rental_fails(
+ self, client: httpx.Client, key: str
+ ) -> None:
+ """Test that rental can only be applied to jokers."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ assert_error_response(
+ api(client, "add", {"key": key, "rental": True}),
+ "BAD_REQUEST",
+ "Rental can only be applied to jokers",
+ )
+
+ def test_add_joker_with_rental_and_eternal(self, client: httpx.Client) -> None:
+ """Test adding a joker with both rental and eternal stickers."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SHOP--jokers.count-0--consumables.count-0--vouchers.count-0",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 0
+ response = api(
+ client, "add", {"key": "j_joker", "rental": True, "eternal": True}
+ )
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 1
+ assert after["jokers"]["cards"][0]["key"] == "j_joker"
+ assert after["jokers"]["cards"][0]["modifier"]["rental"] is True
+ assert after["jokers"]["cards"][0]["modifier"]["eternal"] is True
+
+ def test_add_playing_card_with_rental_fails(self, client: httpx.Client) -> None:
+ """Test that rental cannot be applied to playing cards."""
+ gamestate = load_fixture(
+ client,
+ "add",
+ "state-SELECTING_HAND--jokers.count-0--consumables.count-0--hand.count-8",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ assert_error_response(
+ api(client, "add", {"key": "H_A", "rental": True}),
+ "BAD_REQUEST",
+ "Rental can only be applied to jokers",
+ )
diff --git a/tests/lua/endpoints/test_buy.py b/tests/lua/endpoints/test_buy.py
new file mode 100644
index 0000000..bc00ac2
--- /dev/null
+++ b/tests/lua/endpoints/test_buy.py
@@ -0,0 +1,244 @@
+"""Tests for src/lua/endpoints/buy.lua"""
+
+import httpx
+import pytest
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestBuyEndpoint:
+ """Test basic buy endpoint functionality."""
+
+ @pytest.mark.flaky(reruns=2)
+ def test_buy_no_args(self, client: httpx.Client) -> None:
+ """Test buy endpoint with no arguments."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {}),
+ "BAD_REQUEST",
+ "Invalid arguments. You must provide one of: card, voucher, pack",
+ )
+
+ @pytest.mark.flaky(reruns=2)
+ def test_buy_multi_args(self, client: httpx.Client) -> None:
+ """Test buy endpoint with multiple arguments."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {"card": 0, "voucher": 0}),
+ "BAD_REQUEST",
+ "Invalid arguments. Cannot provide more than one of: card, voucher, or pack",
+ )
+
+ def test_buy_no_card_in_shop_area(self, client: httpx.Client) -> None:
+ """Test buy endpoint with no card in shop area."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.count-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["count"] == 0
+ assert_error_response(
+ api(client, "buy", {"card": 0}),
+ "BAD_REQUEST",
+ "No jokers/consumables/cards in the shop. Reroll to restock the shop",
+ )
+
+ def test_buy_invalid_index(self, client: httpx.Client) -> None:
+ """Test buy endpoint with invalid card index."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {"card": 999}),
+ "BAD_REQUEST",
+ "Card index out of range. Index: 999, Available cards: 2",
+ )
+
+ def test_buy_insufficient_funds(self, client: httpx.Client) -> None:
+ """Test buy endpoint when player has insufficient funds."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--money-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["money"] == 0
+ assert_error_response(
+ api(client, "buy", {"card": 0}),
+ "BAD_REQUEST",
+ "Card is not affordable. Cost: 5, Available money: 0",
+ )
+
+ def test_buy_joker_slots_full(self, client: httpx.Client) -> None:
+ """Test buy endpoint when player has the maximum number of consumables."""
+ gamestate = load_fixture(
+ client, "buy", "state-SHOP--jokers.count-5--shop.cards[0].set-JOKER"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 5
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {"card": 0}),
+ "BAD_REQUEST",
+ "Cannot purchase joker card, joker slots are full. Current: 5, Limit: 5",
+ )
+
+ def test_buy_consumable_slots_full(self, client: httpx.Client) -> None:
+ """Test buy endpoint when player has the maximum number of consumables."""
+ gamestate = load_fixture(
+ client,
+ "buy",
+ "state-SHOP--consumables.count-2--shop.cards[1].set-PLANET",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 2
+ assert gamestate["shop"]["cards"][1]["set"] == "PLANET"
+ assert_error_response(
+ api(client, "buy", {"card": 1}),
+ "BAD_REQUEST",
+ "Cannot purchase consumable card, consumable slots are full. Current: 2, Limit: 2",
+ )
+
+ def test_buy_vouchers_slot_empty(self, client: httpx.Client) -> None:
+ """Test buy endpoint when player has the maximum number of vouchers."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--voucher.count-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["vouchers"]["count"] == 0
+ assert_error_response(
+ api(client, "buy", {"voucher": 0}),
+ "BAD_REQUEST",
+ "No vouchers to redeem. Defeat boss blind to restock",
+ )
+
+ @pytest.mark.skip(
+ reason="Fixture not available yet. We need to be able to skip a pack."
+ )
+ def test_buy_packs_slot_empty(self, client: httpx.Client) -> None:
+ """Test buy endpoint when player has the maximum number of vouchers."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--packs.count-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["packs"]["count"] == 0
+ assert_error_response(
+ api(client, "buy", {"voucher": 0}),
+ "BAD_REQUEST",
+ "No vouchers to redeem. Defeat boss blind to restock",
+ )
+
+ def test_buy_joker_success(self, client: httpx.Client) -> None:
+ """Test buying a joker card from shop."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ response = api(client, "buy", {"card": 0})
+ gamestate = assert_gamestate_response(response)
+ assert gamestate["jokers"]["cards"][0]["set"] == "JOKER"
+
+ def test_buy_consumable_success(self, client: httpx.Client) -> None:
+ """Test buying a consumable card (Planet/Tarot/Spectral) from shop."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[1].set-PLANET")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][1]["set"] == "PLANET"
+ response = api(client, "buy", {"card": 1})
+ gamestate = assert_gamestate_response(response)
+ assert gamestate["consumables"]["cards"][0]["set"] == "PLANET"
+
+ def test_buy_voucher_success(self, client: httpx.Client) -> None:
+ """Test buying a voucher from shop."""
+ gamestate = load_fixture(
+ client, "buy", "state-SHOP--voucher.cards[0].set-VOUCHER"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["vouchers"]["cards"][0]["set"] == "VOUCHER"
+ response = api(client, "buy", {"voucher": 0})
+ gamestate = assert_gamestate_response(response)
+ assert gamestate["used_vouchers"] is not None
+ assert len(gamestate["used_vouchers"]) > 0
+
+ def test_buy_packs_success(self, client: httpx.Client) -> None:
+ """Test buying a pack from shop."""
+ gamestate = load_fixture(
+ client,
+ "buy",
+ "state-SHOP--packs.cards[0].label-Buffoon+Pack--packs.cards[1].label-Standard+Pack",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["packs"]["cards"][0]["label"] == "Buffoon Pack"
+ assert gamestate["packs"]["cards"][1]["label"] == "Standard Pack"
+ response = api(client, "buy", {"pack": 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
+ gamestate = load_fixture(client, "buy", "state-SHOP--money-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["money"] == 0
+
+ # Add Credit Card joker (gives +$20 credit, can go to -$20)
+ response = api(client, "add", {"key": "j_credit_card"})
+ gamestate = assert_gamestate_response(response)
+ assert any(j["key"] == "j_credit_card" for j in gamestate["jokers"]["cards"])
+
+ # Should be able to buy a card costing <= $20 even with $0 (due to credit)
+ card_cost = gamestate["shop"]["cards"][0]["cost"]["buy"]
+ assert card_cost <= 20 # Credit Card gives $20 credit
+
+ response = api(client, "buy", {"card": 0})
+ gamestate = assert_gamestate_response(response)
+ # Money should be negative now
+ assert gamestate["money"] < 0
+
+
+class TestBuyEndpointValidation:
+ """Test buy endpoint parameter validation."""
+
+ def test_invalid_card_type_string(self, client: httpx.Client) -> None:
+ """Test that buy fails when card parameter is a string instead of integer."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {"card": "INVALID_STRING"}),
+ "BAD_REQUEST",
+ "Field 'card' must be an integer",
+ )
+
+ def test_invalid_voucher_type_string(self, client: httpx.Client) -> None:
+ """Test that buy fails when voucher parameter is a string instead of integer."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {"voucher": "INVALID_STRING"}),
+ "BAD_REQUEST",
+ "Field 'voucher' must be an integer",
+ )
+
+ def test_invalid_pack_type_string(self, client: httpx.Client) -> None:
+ """Test that buy fails when pack parameter is a string instead of integer."""
+ gamestate = load_fixture(client, "buy", "state-SHOP--shop.cards[0].set-JOKER")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["shop"]["cards"][0]["set"] == "JOKER"
+ assert_error_response(
+ api(client, "buy", {"pack": "INVALID_STRING"}),
+ "BAD_REQUEST",
+ "Field 'pack' must be an integer",
+ )
+
+
+class TestBuyEndpointStateRequirements:
+ """Test buy endpoint state requirements."""
+
+ def test_buy_from_BLIND_SELECT(self, client: httpx.Client) -> None:
+ """Test that buy fails when not in SHOP state."""
+ gamestate = load_fixture(client, "buy", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "buy", {"card": 0}),
+ "INVALID_STATE",
+ "Method 'buy' requires one of these states: SHOP",
+ )
diff --git a/tests/lua/endpoints/test_cash_out.py b/tests/lua/endpoints/test_cash_out.py
index 151ec21..b9b8bae 100644
--- a/tests/lua/endpoints/test_cash_out.py
+++ b/tests/lua/endpoints/test_cash_out.py
@@ -1,65 +1,35 @@
-import socket
-from typing import Generator
+"""Tests for src/lua/endpoints/cash_out.lua"""
-import pytest
+import httpx
-from balatrobot.enums import ErrorCode, State
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
-from ..conftest import assert_error_response, send_and_receive_api_message
+class TestCashOutEndpoint:
+ """Test basic cash_out endpoint functionality."""
-class TestCashOut:
- """Tests for the cash_out API endpoint."""
+ def test_cash_out_from_ROUND_EVAL(self, client: httpx.Client) -> None:
+ """Test cashing out from ROUND_EVAL state."""
+ gamestate = load_fixture(client, "cash_out", "state-ROUND_EVAL")
+ assert gamestate["state"] == "ROUND_EVAL"
+ response = api(client, "cash_out", {})
+ assert_gamestate_response(response, state="SHOP")
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Set up and tear down each test method."""
- # Start a run
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "OOOO155", # four of a kind in first hand
- }
- send_and_receive_api_message(tcp_client, "start_run", start_run_args)
- # Select blind
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- # Play a winning hand (four of a kind) to reach shop
- game_state = send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
- assert game_state["state"] == State.ROUND_EVAL.value
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_cash_out_success(self, tcp_client: socket.socket) -> None:
- """Test successful cash out returns to shop state."""
- # Cash out should transition to shop state
- game_state = send_and_receive_api_message(tcp_client, "cash_out", {})
-
- # Verify we're in shop state after cash out
- assert game_state["state"] == State.SHOP.value
-
- def test_cash_out_invalid_state_error(self, tcp_client: socket.socket) -> None:
- """Test cash out returns error when not in shop state."""
- # Go to menu first to ensure we're not in shop state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # Try to cash out when not in shop - should return error
- response = send_and_receive_api_message(tcp_client, "cash_out", {})
+class TestCashOutEndpointStateRequirements:
+ """Test cash_out endpoint state requirements."""
- # Verify error response
+ def test_cash_out_from_BLIND_SELECT(self, client: httpx.Client):
+ """Test that cash_out fails when not in ROUND_EVAL state."""
+ gamestate = load_fixture(client, "cash_out", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
assert_error_response(
- response,
- "Cannot cash out when not in round evaluation",
- ["current_state"],
- ErrorCode.INVALID_GAME_STATE.value,
+ api(client, "cash_out", {}),
+ "INVALID_STATE",
+ "Method 'cash_out' requires one of these states: ROUND_EVAL",
)
diff --git a/tests/lua/endpoints/test_discard.py b/tests/lua/endpoints/test_discard.py
new file mode 100644
index 0000000..422deae
--- /dev/null
+++ b/tests/lua/endpoints/test_discard.py
@@ -0,0 +1,111 @@
+"""Tests for src/lua/endpoints/discard.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestDiscardEndpoint:
+ """Test basic discard endpoint functionality."""
+
+ def test_discard_zero_cards(self, client: httpx.Client) -> None:
+ """Test discard endpoint with empty cards array."""
+ gamestate = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "discard", {"cards": []}),
+ "BAD_REQUEST",
+ "Must provide at least one card to discard",
+ )
+
+ def test_discard_too_many_cards(self, client: httpx.Client) -> None:
+ """Test discard endpoint with more cards than limit."""
+ gamestate = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "discard", {"cards": [0, 1, 2, 3, 4, 5]}),
+ "BAD_REQUEST",
+ "You can only discard 5 cards",
+ )
+
+ def test_discard_out_of_range_cards(self, client: httpx.Client) -> None:
+ """Test discard endpoint with invalid card index."""
+ gamestate = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "discard", {"cards": [999]}),
+ "BAD_REQUEST",
+ "Invalid card index: 999",
+ )
+
+ def test_discard_no_discards_left(self, client: httpx.Client) -> None:
+ """Test discard endpoint when no discards remain."""
+ gamestate = load_fixture(
+ client, "discard", "state-SELECTING_HAND--round.discards_left-0"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["round"]["discards_left"] == 0
+ assert_error_response(
+ api(client, "discard", {"cards": [0]}),
+ "BAD_REQUEST",
+ "No discards left",
+ )
+
+ def test_discard_valid_single_card(self, client: httpx.Client) -> None:
+ """Test discard endpoint with valid single card."""
+ before = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert before["state"] == "SELECTING_HAND"
+ response = api(client, "discard", {"cards": [0]})
+ after = assert_gamestate_response(response, state="SELECTING_HAND")
+ assert after["round"]["discards_left"] == before["round"]["discards_left"] - 1
+
+ def test_discard_valid_multiple_cards(self, client: httpx.Client) -> None:
+ """Test discard endpoint with valid multiple cards."""
+ before = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert before["state"] == "SELECTING_HAND"
+ response = api(client, "discard", {"cards": [1, 2, 3]})
+ after = assert_gamestate_response(response, state="SELECTING_HAND")
+ assert after["round"]["discards_left"] == before["round"]["discards_left"] - 1
+
+
+class TestDiscardEndpointValidation:
+ """Test discard endpoint parameter validation."""
+
+ def test_missing_cards_parameter(self, client: httpx.Client):
+ """Test that discard fails when cards parameter is missing."""
+ gamestate = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "discard", {}),
+ "BAD_REQUEST",
+ "Missing required field 'cards'",
+ )
+
+ def test_invalid_cards_type(self, client: httpx.Client):
+ """Test that discard fails when cards parameter is not an array."""
+ gamestate = load_fixture(client, "discard", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "discard", {"cards": "INVALID_CARDS"}),
+ "BAD_REQUEST",
+ "Field 'cards' must be an array",
+ )
+
+
+class TestDiscardEndpointStateRequirements:
+ """Test discard endpoint state requirements."""
+
+ def test_discard_from_BLIND_SELECT(self, client: httpx.Client):
+ """Test that discard fails when not in SELECTING_HAND state."""
+ gamestate = load_fixture(client, "discard", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "discard", {"cards": [0]}),
+ "INVALID_STATE",
+ "Method 'discard' requires one of these states: SELECTING_HAND",
+ )
diff --git a/tests/lua/endpoints/test_gamestate.py b/tests/lua/endpoints/test_gamestate.py
new file mode 100644
index 0000000..6e868b2
--- /dev/null
+++ b/tests/lua/endpoints/test_gamestate.py
@@ -0,0 +1,32 @@
+"""Tests for src/lua/endpoints/gamestate.lua"""
+
+import httpx
+
+from tests.lua.conftest import api, assert_gamestate_response, load_fixture
+
+
+class TestGamestateEndpoint:
+ """Test basic gamestate endpoint and gamestate response structure."""
+
+ def test_gamestate_from_MENU(self, client: httpx.Client) -> None:
+ """Test that gamestate endpoint from MENU state is valid."""
+ api(client, "menu", {})
+ response = api(client, "gamestate", {})
+ assert_gamestate_response(response, state="MENU")
+
+ 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"
+ 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"
+ response = api(client, "gamestate", {})
+ assert_gamestate_response(
+ response,
+ state="BLIND_SELECT",
+ round_num=0,
+ deck="RED",
+ stake="WHITE",
+ )
diff --git a/tests/lua/endpoints/test_get_gamestate.py b/tests/lua/endpoints/test_get_gamestate.py
deleted file mode 100644
index 4e1464c..0000000
--- a/tests/lua/endpoints/test_get_gamestate.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import State
-
-from ..conftest import send_and_receive_api_message
-
-
-class TestGetGameState:
- """Tests for the get_game_state API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Set up and tear down each test method."""
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_get_game_state_response(self, tcp_client: socket.socket) -> None:
- """Test get_game_state message returns valid JSON game state."""
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert isinstance(game_state, dict)
-
- def test_game_state_structure(self, tcp_client: socket.socket) -> None:
- """Test that game state contains expected top-level fields."""
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
-
- assert isinstance(game_state, dict)
-
- expected_keys = {"state", "game"}
- assert expected_keys.issubset(game_state.keys())
- assert isinstance(game_state["state"], int)
- assert isinstance(game_state["game"], (dict, type(None)))
-
- def test_game_state_during_run(self, tcp_client: socket.socket) -> None:
- """Test getting game state at different points during a run."""
- # Start a run
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- initial_state = send_and_receive_api_message(
- tcp_client, "start_run", start_run_args
- )
- assert initial_state["state"] == State.BLIND_SELECT.value
-
- # Get game state again to ensure it's consistent
- current_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
-
- assert current_state["state"] == State.BLIND_SELECT.value
- assert current_state["state"] == initial_state["state"]
diff --git a/tests/lua/endpoints/test_get_save_info.py b/tests/lua/endpoints/test_get_save_info.py
deleted file mode 100644
index a46802a..0000000
--- a/tests/lua/endpoints/test_get_save_info.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from ..conftest import send_and_receive_api_message
-
-
-class TestGetSaveInfo:
- """Tests for the get_save_info API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Ensure we return to menu after each test."""
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_get_save_info_response(self, tcp_client: socket.socket) -> None:
- """Basic sanity check that the endpoint returns a dict."""
- save_info = send_and_receive_api_message(tcp_client, "get_save_info", {})
- assert isinstance(save_info, dict)
-
- def test_save_info_structure(self, tcp_client: socket.socket) -> None:
- """Validate expected keys and types are present in the response."""
- save_info = send_and_receive_api_message(tcp_client, "get_save_info", {})
-
- # Required top-level keys
- expected_keys = {
- "profile_path",
- "save_directory",
- "save_file_path",
- "has_active_run",
- "save_exists",
- }
- assert expected_keys.issubset(save_info.keys())
-
- # Types
- assert isinstance(save_info["has_active_run"], bool)
- assert isinstance(save_info["save_exists"], bool)
- assert (
- # The save profile is always an index (1-3)
- isinstance(save_info.get("profile_path"), (int, type(None)))
- and isinstance(save_info.get("save_directory"), (str, type(None)))
- and isinstance(save_info.get("save_file_path"), (str, type(None)))
- )
-
- # If a path is present, it should reference the save file
- if save_info.get("save_file_path"):
- assert "save.jkr" in save_info["save_file_path"]
-
- def test_has_active_run_flag(self, tcp_client: socket.socket) -> None:
- """has_active_run should be False at menu and True after starting a run."""
- info_before = send_and_receive_api_message(tcp_client, "get_save_info", {})
- assert isinstance(info_before["has_active_run"], bool)
-
- # Start a run
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- send_and_receive_api_message(tcp_client, "start_run", start_run_args)
-
- info_during = send_and_receive_api_message(tcp_client, "get_save_info", {})
- assert info_during["has_active_run"] is True
diff --git a/tests/lua/endpoints/test_go_to_menu.py b/tests/lua/endpoints/test_go_to_menu.py
deleted file mode 100644
index c0e3a0c..0000000
--- a/tests/lua/endpoints/test_go_to_menu.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import socket
-
-from balatrobot.enums import State
-
-from ..conftest import send_and_receive_api_message
-
-
-class TestGoToMenu:
- """Tests for the go_to_menu API endpoint."""
-
- def test_go_to_menu(self, tcp_client: socket.socket) -> None:
- """Test going to the main menu."""
- game_state = send_and_receive_api_message(tcp_client, "go_to_menu", {})
- assert game_state["state"] == State.MENU.value
-
- def test_go_to_menu_from_run(self, tcp_client: socket.socket) -> None:
- """Test going to menu from within a run."""
- # First start a run
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- initial_state = send_and_receive_api_message(
- tcp_client, "start_run", start_run_args
- )
- assert initial_state["state"] == State.BLIND_SELECT.value
-
- # Now go to menu
- menu_state = send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- assert menu_state["state"] == State.MENU.value
diff --git a/tests/lua/endpoints/test_health.py b/tests/lua/endpoints/test_health.py
new file mode 100644
index 0000000..6724fc9
--- /dev/null
+++ b/tests/lua/endpoints/test_health.py
@@ -0,0 +1,32 @@
+# tests/lua/endpoints/test_health.py
+# Tests for src/lua/endpoints/health.lua
+#
+# Tests the health check endpoint:
+# - Basic health check functionality
+# - Response structure and fields
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_gamestate_response,
+ assert_health_response,
+ load_fixture,
+)
+
+
+class TestHealthEndpoint:
+ """Test basic health endpoint functionality."""
+
+ def test_health_from_MENU(self, client: httpx.Client) -> None:
+ """Test that health check returns status ok."""
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
+ assert_health_response(api(client, "health", {}))
+
+ def test_health_from_BLIND_SELECT(self, client: httpx.Client) -> None:
+ """Test that health check returns status ok."""
+ save = "state-BLIND_SELECT"
+ gamestate = load_fixture(client, "health", save)
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_health_response(api(client, "health", {}))
diff --git a/tests/lua/endpoints/test_load.py b/tests/lua/endpoints/test_load.py
new file mode 100644
index 0000000..ac94b60
--- /dev/null
+++ b/tests/lua/endpoints/test_load.py
@@ -0,0 +1,65 @@
+"""Tests for src/lua/endpoints/load.lua"""
+
+from pathlib import Path
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_path_response,
+ get_fixture_path,
+ load_fixture,
+)
+
+
+class TestLoadEndpoint:
+ """Test basic load endpoint functionality."""
+
+ def test_load_from_fixture(self, client: httpx.Client) -> None:
+ """Test that load succeeds with a valid fixture file."""
+ gamestate = load_fixture(client, "load", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ fixture_path = get_fixture_path("load", "state-BLIND_SELECT")
+ response = api(client, "load", {"path": str(fixture_path)})
+ assert_path_response(response)
+ assert response["result"]["path"] == str(fixture_path)
+
+ def test_load_save_roundtrip(self, client: httpx.Client, tmp_path: Path) -> None:
+ """Test that a loaded fixture can be saved and loaded again."""
+ # Load fixture
+ gamestate = load_fixture(client, "load", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ fixture_path = get_fixture_path("load", "state-BLIND_SELECT")
+ load_response = api(client, "load", {"path": str(fixture_path)})
+ assert_path_response(load_response)
+
+ # Save to temp path
+ temp_file = tmp_path / "save"
+ save_response = api(client, "save", {"path": str(temp_file)})
+ assert_path_response(save_response)
+ assert temp_file.exists()
+
+ # Load the saved file back
+ load_again_response = api(client, "load", {"path": str(temp_file)})
+ assert_path_response(load_again_response)
+
+
+class TestLoadValidation:
+ """Test load endpoint parameter validation."""
+
+ def test_missing_path_parameter(self, client: httpx.Client) -> None:
+ """Test that load fails when path parameter is missing."""
+ assert_error_response(
+ api(client, "load", {}),
+ "BAD_REQUEST",
+ "Missing required field 'path'",
+ )
+
+ def test_invalid_path_type(self, client: httpx.Client) -> None:
+ """Test that load fails when path is not a string."""
+ assert_error_response(
+ api(client, "load", {"path": 123}),
+ "BAD_REQUEST",
+ "Field 'path' must be of type string",
+ )
diff --git a/tests/lua/endpoints/test_load_save.py b/tests/lua/endpoints/test_load_save.py
deleted file mode 100644
index 3241a54..0000000
--- a/tests/lua/endpoints/test_load_save.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import socket
-from pathlib import Path
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode
-
-from ..conftest import (
- assert_error_response,
- prepare_checkpoint,
- send_and_receive_api_message,
-)
-
-
-class TestLoadSave:
- """Tests for the load_save API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Ensure we return to menu after each test."""
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_load_save_success(self, tcp_client: socket.socket) -> None:
- """Successfully load a checkpoint and verify a run is active."""
- checkpoint_path = Path(__file__).parent / "checkpoints" / "plasma_deck.jkr"
- game_state = prepare_checkpoint(tcp_client, checkpoint_path)
-
- # Basic structure validations
- assert isinstance(game_state, dict)
- assert "state" in game_state
- assert isinstance(game_state["state"], int)
- assert "game" in game_state
- assert isinstance(game_state["game"], dict)
-
- def test_load_save_missing_required_arg(self, tcp_client: socket.socket) -> None:
- """Missing save_path should return an error response."""
- response = send_and_receive_api_message(tcp_client, "load_save", {})
- assert_error_response(
- response,
- "Missing required field: save_path",
- expected_error_code=ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_load_save_invalid_path(self, tcp_client: socket.socket) -> None:
- """Invalid path should return error with MISSING_GAME_OBJECT code."""
- response = send_and_receive_api_message(
- tcp_client, "load_save", {"save_path": "nonexistent/save.jkr"}
- )
- assert_error_response(
- response,
- "Failed to load save file",
- expected_context_keys=["save_path"],
- expected_error_code=ErrorCode.MISSING_GAME_OBJECT.value,
- )
diff --git a/tests/lua/endpoints/test_menu.py b/tests/lua/endpoints/test_menu.py
new file mode 100644
index 0000000..f40837f
--- /dev/null
+++ b/tests/lua/endpoints/test_menu.py
@@ -0,0 +1,22 @@
+"""Tests for src/lua/endpoints/menu.lua"""
+
+import httpx
+
+from tests.lua.conftest import api, assert_gamestate_response, load_fixture
+
+
+class TestMenuEndpoint:
+ """Test basic menu endpoint and menu response structure."""
+
+ def test_menu_from_MENU(self, client: httpx.Client) -> None:
+ """Test that menu endpoint returns state as MENU."""
+ api(client, "menu", {})
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
+
+ def test_menu_from_BLIND_SELECT(self, client: httpx.Client) -> None:
+ """Test that menu endpoint returns state as MENU."""
+ gamestate = load_fixture(client, "menu", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
diff --git a/tests/lua/endpoints/test_next_round.py b/tests/lua/endpoints/test_next_round.py
new file mode 100644
index 0000000..4ddc8e2
--- /dev/null
+++ b/tests/lua/endpoints/test_next_round.py
@@ -0,0 +1,36 @@
+"""Tests for src/lua/endpoints/next_round.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestNextRoundEndpoint:
+ """Test basic next_round endpoint functionality."""
+
+ def test_next_round_from_shop(self, client: httpx.Client) -> None:
+ """Test advancing to next round from SHOP state."""
+ gamestate = load_fixture(client, "next_round", "state-SHOP")
+ assert gamestate["state"] == "SHOP"
+ response = api(client, "next_round", {})
+ assert_gamestate_response(response, state="BLIND_SELECT")
+
+
+class TestNextRoundEndpointStateRequirements:
+ """Test next_round endpoint state requirements."""
+
+ def test_next_round_from_MENU(self, client: httpx.Client):
+ """Test that next_round fails when not in SHOP state."""
+ gamestate = load_fixture(client, "next_round", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ response = api(client, "next_round", {})
+ assert_error_response(
+ response,
+ "INVALID_STATE",
+ "Method 'next_round' requires one of these states: SHOP",
+ )
diff --git a/tests/lua/endpoints/test_play.py b/tests/lua/endpoints/test_play.py
new file mode 100644
index 0000000..8dee552
--- /dev/null
+++ b/tests/lua/endpoints/test_play.py
@@ -0,0 +1,125 @@
+"""Tests for src/lua/endpoints/play.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestPlayEndpoint:
+ """Test basic play endpoint functionality."""
+
+ def test_play_zero_cards(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "play", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "play", {"cards": []}),
+ "BAD_REQUEST",
+ "Must provide at least one card to play",
+ )
+
+ def test_play_six_cards(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "play", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "play", {"cards": [0, 1, 2, 3, 4, 5]}),
+ "BAD_REQUEST",
+ "You can only play 5 cards",
+ )
+
+ def test_play_out_of_range_cards(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "play", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "play", {"cards": [999]}),
+ "BAD_REQUEST",
+ "Invalid card index: 999",
+ )
+
+ def test_play_valid_cards_and_round_active(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "play", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "play", {"cards": [0, 3, 4, 5, 6]})
+ gamestate = assert_gamestate_response(response, state="SELECTING_HAND")
+ assert gamestate["hands"]["Flush"]["played_this_round"] == 1
+ assert gamestate["round"]["chips"] == 260
+
+ def test_play_valid_cards_and_round_won(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "play", "state-SELECTING_HAND--round.chips-200"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["round"]["chips"] == 200
+ response = api(client, "play", {"cards": [0, 3, 4, 5, 6]})
+ assert_gamestate_response(response, state="ROUND_EVAL")
+
+ def test_play_valid_cards_and_game_won(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client,
+ "play",
+ "state-SELECTING_HAND--ante_num-8--blinds.boss.status-CURRENT--round.chips-1000000",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["ante_num"] == 8
+ assert gamestate["blinds"]["boss"]["status"] == "CURRENT"
+ assert gamestate["round"]["chips"] == 1000000
+ response = api(client, "play", {"cards": [0, 3, 4, 5, 6]})
+ assert_gamestate_response(response, won=True)
+
+ def test_play_valid_cards_and_game_over(self, client: httpx.Client) -> None:
+ """Test play endpoint from BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "play", "state-SELECTING_HAND--round.hands_left-1"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["round"]["hands_left"] == 1
+ response = api(client, "play", {"cards": [0]}, timeout=5)
+ assert_gamestate_response(response, state="GAME_OVER")
+
+
+class TestPlayEndpointValidation:
+ """Test play endpoint parameter validation."""
+
+ def test_missing_cards_parameter(self, client: httpx.Client):
+ """Test that play fails when cards parameter is missing."""
+ gamestate = load_fixture(client, "play", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "play", {}),
+ "BAD_REQUEST",
+ "Missing required field 'cards'",
+ )
+
+ def test_invalid_cards_type(self, client: httpx.Client):
+ """Test that play fails when cards parameter is not an array."""
+ gamestate = load_fixture(client, "play", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "play", {"cards": "INVALID_CARDS"}),
+ "BAD_REQUEST",
+ "Field 'cards' must be an array",
+ )
+
+
+class TestPlayEndpointStateRequirements:
+ """Test play endpoint state requirements."""
+
+ def test_play_from_BLIND_SELECT(self, client: httpx.Client):
+ """Test that play fails when not in SELECTING_HAND state."""
+ gamestate = load_fixture(client, "play", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "play", {"cards": [0]}),
+ "INVALID_STATE",
+ "Method 'play' requires one of these states: SELECTING_HAND",
+ )
diff --git a/tests/lua/endpoints/test_play_hand_or_discard.py b/tests/lua/endpoints/test_play_hand_or_discard.py
deleted file mode 100644
index b2049db..0000000
--- a/tests/lua/endpoints/test_play_hand_or_discard.py
+++ /dev/null
@@ -1,277 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestPlayHandOrDiscard:
- """Tests for the play_hand_or_discard API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- """Set up and tear down each test method."""
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "OOOO155", # four of a kind in first hand
- },
- )
- game_state = send_and_receive_api_message(
- tcp_client,
- "skip_or_select_blind",
- {"action": "select"},
- )
- assert game_state["state"] == State.SELECTING_HAND.value
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- @pytest.mark.parametrize(
- "cards,expected_new_cards",
- [
- ([7, 6, 5, 4, 3], 5), # Test playing five cards
- ([0], 1), # Test playing one card
- ],
- )
- def test_play_hand(
- self,
- tcp_client: socket.socket,
- setup_and_teardown: dict,
- cards: list[int],
- expected_new_cards: int,
- ) -> None:
- """Test playing a hand with different numbers of cards."""
- initial_game_state = setup_and_teardown
- play_hand_args = {"action": "play_hand", "cards": cards}
-
- init_card_keys = [
- card["config"]["card_key"] for card in initial_game_state["hand"]["cards"]
- ]
- played_hand_keys = [
- initial_game_state["hand"]["cards"][i]["config"]["card_key"]
- for i in play_hand_args["cards"]
- ]
- game_state = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
- final_card_keys = [
- card["config"]["card_key"] for card in game_state["hand"]["cards"]
- ]
- assert game_state["state"] == State.SELECTING_HAND.value
- assert game_state["game"]["hands_played"] == 1
- assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards
- assert set(final_card_keys) & set(played_hand_keys) == set()
-
- def test_play_hand_winning(self, tcp_client: socket.socket) -> None:
- """Test playing a winning hand (four of a kind)"""
- play_hand_args = {"action": "play_hand", "cards": [0, 1, 2, 3]}
- game_state = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
- assert game_state["state"] == State.ROUND_EVAL.value
-
- def test_play_hands_losing(self, tcp_client: socket.socket) -> None:
- """Test playing a series of losing hands and reach Main menu again."""
- for _ in range(4):
- game_state = send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0]},
- )
- assert game_state["state"] == State.GAME_OVER.value
-
- def test_play_hand_or_discard_invalid_cards(
- self, tcp_client: socket.socket
- ) -> None:
- """Test playing a hand with invalid card indices returns error."""
- play_hand_args = {"action": "play_hand", "cards": [10, 11, 12, 13, 14]}
- response = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
-
- # Should receive error response for invalid card index
- assert_error_response(
- response,
- "Invalid card index",
- ["card_index", "hand_size"],
- ErrorCode.INVALID_CARD_INDEX.value,
- )
-
- def test_play_hand_invalid_action(self, tcp_client: socket.socket) -> None:
- """Test playing a hand with invalid action returns error."""
- play_hand_args = {"action": "invalid_action", "cards": [0, 1, 2, 3, 4]}
- response = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
-
- # Should receive error response for invalid action
- assert_error_response(
- response,
- "Invalid action for play_hand_or_discard",
- ["action"],
- ErrorCode.INVALID_ACTION.value,
- )
-
- @pytest.mark.parametrize(
- "cards,expected_new_cards",
- [
- ([0, 1, 2, 3, 4], 5), # Test discarding five cards
- ([0], 1), # Test discarding one card
- ],
- )
- def test_discard(
- self,
- tcp_client: socket.socket,
- setup_and_teardown: dict,
- cards: list[int],
- expected_new_cards: int,
- ) -> None:
- """Test discarding with different numbers of cards."""
- initial_game_state = setup_and_teardown
- init_discards_left = initial_game_state["game"]["current_round"][
- "discards_left"
- ]
- discard_hand_args = {"action": "discard", "cards": cards}
-
- init_card_keys = [
- card["config"]["card_key"] for card in initial_game_state["hand"]["cards"]
- ]
- discarded_hand_keys = [
- initial_game_state["hand"]["cards"][i]["config"]["card_key"]
- for i in discard_hand_args["cards"]
- ]
- game_state = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", discard_hand_args
- )
- final_card_keys = [
- card["config"]["card_key"] for card in game_state["hand"]["cards"]
- ]
- assert game_state["state"] == State.SELECTING_HAND.value
- assert game_state["game"]["hands_played"] == 0
- assert (
- game_state["game"]["current_round"]["discards_left"]
- == init_discards_left - 1
- )
- assert len(set(final_card_keys) - set(init_card_keys)) == expected_new_cards
- assert set(final_card_keys) & set(discarded_hand_keys) == set()
-
- def test_try_to_discard_when_no_discards_left(
- self, tcp_client: socket.socket
- ) -> None:
- """Test trying to discard when no discards are left."""
- for _ in range(4):
- game_state = send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "discard", "cards": [0]},
- )
- assert game_state["state"] == State.SELECTING_HAND.value
- assert game_state["game"]["hands_played"] == 0
- assert game_state["game"]["current_round"]["discards_left"] == 0
-
- response = send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "discard", "cards": [0]},
- )
-
- # Should receive error response for no discards left
- assert_error_response(
- response,
- "No discards left to perform discard",
- ["discards_left"],
- ErrorCode.NO_DISCARDS_LEFT.value,
- )
-
- def test_play_hand_or_discard_empty_cards(self, tcp_client: socket.socket) -> None:
- """Test playing a hand with no cards returns error."""
- play_hand_args = {"action": "play_hand", "cards": []}
- response = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
-
- # Should receive error response for no cards
- assert_error_response(
- response,
- "Invalid number of cards",
- ["cards_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_play_hand_or_discard_too_many_cards(
- self, tcp_client: socket.socket
- ) -> None:
- """Test playing a hand with more than 5 cards returns error."""
- play_hand_args = {"action": "play_hand", "cards": [0, 1, 2, 3, 4, 5]}
- response = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
-
- # Should receive error response for too many cards
- assert_error_response(
- response,
- "Invalid number of cards",
- ["cards_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_discard_empty_cards(self, tcp_client: socket.socket) -> None:
- """Test discarding with no cards returns error."""
- discard_args = {"action": "discard", "cards": []}
- response = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", discard_args
- )
-
- # Should receive error response for no cards
- assert_error_response(
- response,
- "Invalid number of cards",
- ["cards_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_discard_too_many_cards(self, tcp_client: socket.socket) -> None:
- """Test discarding with more than 5 cards returns error."""
- discard_args = {"action": "discard", "cards": [0, 1, 2, 3, 4, 5, 6]}
- response = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", discard_args
- )
-
- # Should receive error response for too many cards
- assert_error_response(
- response,
- "Invalid number of cards",
- ["cards_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_play_hand_or_discard_invalid_state(
- self, tcp_client: socket.socket
- ) -> None:
- """Test that play_hand_or_discard returns error when not in selecting hand state."""
- # Go to menu to ensure we're not in selecting hand state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # Try to play hand when not in selecting hand state
- error_response = send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3, 4]},
- )
-
- # Verify error response
- assert_error_response(
- error_response,
- "Cannot play hand or discard when not selecting hand",
- ["current_state"],
- ErrorCode.INVALID_GAME_STATE.value,
- )
diff --git a/tests/lua/endpoints/test_rearrange.py b/tests/lua/endpoints/test_rearrange.py
new file mode 100644
index 0000000..744f237
--- /dev/null
+++ b/tests/lua/endpoints/test_rearrange.py
@@ -0,0 +1,234 @@
+"""Tests for src/lua/endpoints/rearrange.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestRearrangeEndpoint:
+ """Test basic rearrange endpoint functionality."""
+
+ def test_rearrange_hand(self, client: httpx.Client) -> None:
+ """Test rearranging hand in selecting hand state."""
+ before = load_fixture(client, "rearrange", "state-SELECTING_HAND--hand.count-8")
+ assert before["state"] == "SELECTING_HAND"
+ assert before["hand"]["count"] == 8
+ prev_ids = [card["id"] for card in before["hand"]["cards"]]
+ permutation = [1, 2, 0, 3, 4, 5, 7, 6]
+ response = api(
+ client,
+ "rearrange",
+ {"hand": permutation},
+ )
+ after = assert_gamestate_response(response)
+ ids = [card["id"] for card in after["hand"]["cards"]]
+ assert ids == [prev_ids[i] for i in permutation]
+
+ def test_rearrange_jokers(self, client: httpx.Client) -> None:
+ """Test rearranging jokers."""
+ before = load_fixture(
+ client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2"
+ )
+ assert before["state"] == "SHOP"
+ assert before["jokers"]["count"] == 4
+ prev_ids = [card["id"] for card in before["jokers"]["cards"]]
+ permutation = [2, 0, 1, 3]
+ response = api(
+ client,
+ "rearrange",
+ {"jokers": permutation},
+ )
+ after = assert_gamestate_response(response)
+ ids = [card["id"] for card in after["jokers"]["cards"]]
+ assert ids == [prev_ids[i] for i in permutation]
+
+ def test_rearrange_consumables(self, client: httpx.Client) -> None:
+ """Test rearranging consumables."""
+ before = load_fixture(
+ client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2"
+ )
+ assert before["state"] == "SHOP"
+ assert before["consumables"]["count"] == 2
+ prev_ids = [card["id"] for card in before["consumables"]["cards"]]
+ permutation = [1, 0]
+ response = api(
+ client,
+ "rearrange",
+ {"consumables": permutation},
+ )
+ after = assert_gamestate_response(response)
+ ids = [card["id"] for card in after["consumables"]["cards"]]
+ assert ids == [prev_ids[i] for i in permutation]
+
+
+class TestRearrangeEndpointValidation:
+ """Test rearrange endpoint parameter validation."""
+
+ def test_no_parameters_provided(self, client: httpx.Client) -> None:
+ """Test error when no rearrange type specified."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SELECTING_HAND--hand.count-8"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "rearrange", {})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Must provide exactly one of: hand, jokers, or consumables",
+ )
+
+ def test_multiple_parameters_provided(self, client: httpx.Client) -> None:
+ """Test error when multiple rearrange types specified."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SELECTING_HAND--hand.count-8"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(
+ client, "rearrange", {"hand": [], "jokers": [], "consumables": []}
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Can only rearrange one type at a time",
+ )
+
+ def test_wrong_array_length_hand(self, client: httpx.Client) -> None:
+ """Test error when hand array wrong length."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SELECTING_HAND--hand.count-8"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(
+ client,
+ "rearrange",
+ {"hand": [0, 1, 2, 3]},
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Must provide exactly 8 indices for hand",
+ )
+
+ def test_wrong_array_length_jokers(self, client: httpx.Client) -> None:
+ """Test error when jokers array wrong length."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 4
+ response = api(
+ client,
+ "rearrange",
+ {"jokers": [0, 1, 2]},
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Must provide exactly 4 indices for jokers",
+ )
+
+ def test_wrong_array_length_consumables(self, client: httpx.Client) -> None:
+ """Test error when consumables array wrong length."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 2
+ response = api(
+ client,
+ "rearrange",
+ {"consumables": [0, 1, 2]},
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Must provide exactly 2 indices for consumables",
+ )
+
+ def test_invalid_card_index(self, client: httpx.Client) -> None:
+ """Test error when card index out of range."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SELECTING_HAND--hand.count-8"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(
+ client,
+ "rearrange",
+ {"hand": [-1, 1, 2, 3, 4, 5, 6, 7]},
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Index out of range for hand: -1",
+ )
+
+ def test_duplicate_indices(self, client: httpx.Client) -> None:
+ """Test error when indices contain duplicates."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SELECTING_HAND--hand.count-8"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hand"]["count"] == 8
+ response = api(
+ client,
+ "rearrange",
+ {"hand": [1, 1, 2, 3, 4, 5, 6, 7]},
+ )
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Duplicate index in hand: 1",
+ )
+
+
+class TestRearrangeEndpointStateRequirements:
+ """Test rearrange endpoint state requirements."""
+
+ def test_rearrange_hand_from_wrong_state(self, client: httpx.Client) -> None:
+ """Test that rearranging hand fails from wrong state."""
+ gamestate = load_fixture(client, "rearrange", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "rearrange", {"hand": [0, 1, 2, 3, 4, 5, 6, 7]}),
+ "INVALID_STATE",
+ "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP",
+ )
+
+ def test_rearrange_jokers_from_wrong_state(self, client: httpx.Client) -> None:
+ """Test that rearranging jokers fails from wrong state."""
+ gamestate = load_fixture(client, "rearrange", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "rearrange", {"jokers": [0, 1, 2, 3, 4]}),
+ "INVALID_STATE",
+ "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP",
+ )
+
+ def test_rearrange_consumables_from_wrong_state(self, client: httpx.Client) -> None:
+ """Test that rearranging consumables fails from wrong state."""
+ gamestate = load_fixture(client, "rearrange", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "rearrange", {"jokers": [0, 1]}),
+ "INVALID_STATE",
+ "Method 'rearrange' requires one of these states: SELECTING_HAND, SHOP",
+ )
+
+ def test_rearrange_hand_from_shop(self, client: httpx.Client) -> None:
+ """Test that rearranging hand fails from SHOP."""
+ gamestate = load_fixture(
+ client, "rearrange", "state-SHOP--jokers.count-4--consumables.count-2"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert_error_response(
+ api(client, "rearrange", {"hand": [0, 1, 2, 3, 4, 5, 6, 7]}),
+ "INVALID_STATE",
+ "Can only rearrange hand during hand selection",
+ )
diff --git a/tests/lua/endpoints/test_rearrange_consumables.py b/tests/lua/endpoints/test_rearrange_consumables.py
deleted file mode 100644
index 7842b2f..0000000
--- a/tests/lua/endpoints/test_rearrange_consumables.py
+++ /dev/null
@@ -1,257 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestRearrangeConsumables:
- """Tests for the rearrange_consumables API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- """Start a run, reach shop phase, buy consumables, then enter selecting hand phase."""
- game_state = send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "seed": "OOOO155",
- "stake": 1,
- "challenge": "Bram Poker", # it starts with two consumable
- },
- )
-
- assert len(game_state["consumables"]["cards"]) == 2
-
- yield game_state
-
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Success scenarios
- # ------------------------------------------------------------------
-
- def test_rearrange_consumables_success(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Reverse the consumable order and verify the API response reflects it."""
- initial_state = setup_and_teardown
- initial_consumables = initial_state["consumables"]["cards"]
- consumables_count: int = len(initial_consumables)
-
- # Reverse order indices (API expects zero-based indices)
- new_order = list(range(consumables_count - 1, -1, -1))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": new_order},
- )
-
- # Compare sort_id ordering to make sure it's reversed
- initial_sort_ids = [consumable["sort_id"] for consumable in initial_consumables]
- final_sort_ids = [
- consumable["sort_id"] for consumable in final_state["consumables"]["cards"]
- ]
- assert final_sort_ids == list(reversed(initial_sort_ids))
-
- def test_rearrange_consumables_noop(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sending indices in current order should leave the consumables unchanged."""
- initial_state = setup_and_teardown
- initial_consumables = initial_state["consumables"]["cards"]
- consumables_count: int = len(initial_consumables)
-
- # Existing order indices (0-based)
- current_order = list(range(consumables_count))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": current_order},
- )
-
- initial_sort_ids = [consumable["sort_id"] for consumable in initial_consumables]
- final_sort_ids = [
- consumable["sort_id"] for consumable in final_state["consumables"]["cards"]
- ]
- assert final_sort_ids == initial_sort_ids
-
- def test_rearrange_consumables_single_consumable(
- self, tcp_client: socket.socket
- ) -> None:
- """Test rearranging when only one consumable is available."""
- # Start a simpler setup with just one consumable
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {"deck": "Red Deck", "seed": "OOOO155", "stake": 1},
- )
-
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
-
- send_and_receive_api_message(tcp_client, "cash_out", {})
-
- # Buy only one consumable
- send_and_receive_api_message(
- tcp_client, "shop", {"index": 1, "action": "buy_card"}
- )
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": [0]},
- )
-
- assert len(final_state["consumables"]["cards"]) == 1
-
- # Clean up
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_rearrange_consumables_invalid_number_of_consumables(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing an index list with the wrong length should error."""
- consumables_count = len(setup_and_teardown["consumables"]["cards"])
- invalid_order = list(range(consumables_count - 1)) # one short
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": invalid_order},
- )
-
- assert_error_response(
- response,
- "Invalid number of consumables to rearrange",
- ["consumables_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_consumables_out_of_range_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Including an index >= consumables count should error."""
- consumables_count = len(setup_and_teardown["consumables"]["cards"])
- order = list(range(consumables_count))
- order[-1] = consumables_count # out-of-range zero-based index
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": order},
- )
-
- assert_error_response(
- response,
- "Consumable index out of range",
- ["index", "max_index"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_consumables_no_consumables_available(
- self, tcp_client: socket.socket
- ) -> None:
- """Calling rearrange_consumables when no consumables are available should error."""
- # Start a run without buying consumables
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {"deck": "Red Deck", "stake": 1, "seed": "OOOO155"},
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": []},
- )
-
- assert_error_response(
- response,
- "No consumables available to rearrange",
- ["consumables_available"],
- ErrorCode.MISSING_GAME_OBJECT.value,
- )
-
- # Clean up
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_rearrange_consumables_missing_required_field(
- self, tcp_client: socket.socket
- ) -> None:
- """Calling rearrange_consumables without the consumables field should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {}, # Missing required 'consumables' field
- )
-
- assert_error_response(
- response,
- "Missing required field: consumables",
- ["field"],
- ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_rearrange_consumables_negative_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing negative indices should error (after 0-to-1 based conversion)."""
- consumables_count = len(setup_and_teardown["consumables"]["cards"])
- order = list(range(consumables_count))
- order[0] = -1 # negative index
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": order},
- )
-
- assert_error_response(
- response,
- "Consumable index out of range",
- ["index", "max_index"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_consumables_duplicate_indices(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing duplicate indices should work (last occurrence wins)."""
- consumables_count = len(setup_and_teardown["consumables"]["cards"])
-
- if consumables_count >= 2:
- # Use duplicate index (this should work in current implementation)
- order = [0, 0] # duplicate first index
- if consumables_count > 2:
- order.extend(range(2, consumables_count))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_consumables",
- {"consumables": order},
- )
-
- assert len(final_state["consumables"]["cards"]) == consumables_count
diff --git a/tests/lua/endpoints/test_rearrange_hand.py b/tests/lua/endpoints/test_rearrange_hand.py
deleted file mode 100644
index 7ca9ef0..0000000
--- a/tests/lua/endpoints/test_rearrange_hand.py
+++ /dev/null
@@ -1,154 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestRearrangeHand:
- """Tests for the rearrange_hand API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- """Start a run, reach SELECTING_HAND phase, yield initial state, then clean up."""
- # Begin a run and select the first blind to obtain an initial hand
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "TESTSEED",
- },
- )
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
- assert game_state["state"] == State.SELECTING_HAND.value
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Success scenario
- # ------------------------------------------------------------------
-
- def test_rearrange_hand_success(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Reverse the hand order and verify the API response reflects it."""
- initial_state = setup_and_teardown
- initial_cards = initial_state["hand"]["cards"]
- hand_size: int = len(initial_cards)
-
- # Reverse order indices (API expects zero-based indices)
- new_order = list(range(hand_size - 1, -1, -1))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_hand",
- {"cards": new_order},
- )
-
- # Ensure we remain in selecting hand state
- assert final_state["state"] == State.SELECTING_HAND.value
-
- # Compare card_key ordering to make sure it's reversed
- initial_keys = [card["config"]["card_key"] for card in initial_cards]
- final_keys = [
- card["config"]["card_key"] for card in final_state["hand"]["cards"]
- ]
- assert final_keys == list(reversed(initial_keys))
-
- def test_rearrange_hand_noop(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sending indices in current order should leave the hand unchanged."""
- initial_state = setup_and_teardown
- initial_cards = initial_state["hand"]["cards"]
- hand_size: int = len(initial_cards)
-
- # Existing order indices (0-based)
- current_order = list(range(hand_size))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_hand",
- {"cards": current_order},
- )
-
- assert final_state["state"] == State.SELECTING_HAND.value
-
- initial_keys = [card["config"]["card_key"] for card in initial_cards]
- final_keys = [
- card["config"]["card_key"] for card in final_state["hand"]["cards"]
- ]
- assert final_keys == initial_keys
-
- # ------------------------------------------------------------------
- # Validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_rearrange_hand_invalid_number_of_cards(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing an index list with the wrong length should error."""
- hand_size = len(setup_and_teardown["hand"]["cards"])
- invalid_order = list(range(hand_size - 1)) # one short
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_hand",
- {"cards": invalid_order},
- )
-
- assert_error_response(
- response,
- "Invalid number of cards to rearrange",
- ["cards_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_hand_out_of_range_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Including an index >= hand size should error."""
- hand_size = len(setup_and_teardown["hand"]["cards"])
- order = list(range(hand_size))
- order[-1] = hand_size # out-of-range zero-based index
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_hand",
- {"cards": order},
- )
-
- assert_error_response(
- response,
- "Card index out of range",
- ["index", "max_index"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_hand_invalid_state(self, tcp_client: socket.socket) -> None:
- """Calling rearrange_hand outside of SELECTING_HAND should error."""
- # Ensure we're in MENU state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_hand",
- {"cards": [0]},
- )
-
- assert_error_response(
- response,
- "Cannot rearrange hand when not selecting hand",
- ["current_state"],
- ErrorCode.INVALID_GAME_STATE.value,
- )
diff --git a/tests/lua/endpoints/test_rearrange_jokers.py b/tests/lua/endpoints/test_rearrange_jokers.py
deleted file mode 100644
index 973720e..0000000
--- a/tests/lua/endpoints/test_rearrange_jokers.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestRearrangeJokers:
- """Tests for the rearrange_jokers API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- """Start a run, reach SELECTING_HAND phase with jokers, yield initial state, then clean up."""
- # Begin a run with The Omelette challenge which starts with jokers
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": "The Omelette",
- "seed": "OOOO155",
- },
- )
-
- # Select blind to enter SELECTING_HAND state with jokers already available
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- assert game_state["state"] == State.SELECTING_HAND.value
-
- # Skip if we don't have enough jokers to test with
- if (
- not game_state.get("jokers")
- or not game_state["jokers"].get("cards")
- or len(game_state["jokers"]["cards"]) < 2
- ):
- pytest.skip("Not enough jokers available for testing rearrange_jokers")
-
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Success scenario
- # ------------------------------------------------------------------
-
- def test_rearrange_jokers_success(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Reverse the joker order and verify the API response reflects it."""
- initial_state = setup_and_teardown
- initial_jokers = initial_state["jokers"]["cards"]
- jokers_count: int = len(initial_jokers)
-
- # Reverse order indices (API expects zero-based indices)
- new_order = list(range(jokers_count - 1, -1, -1))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_jokers",
- {"jokers": new_order},
- )
-
- # Ensure we remain in selecting hand state
- assert final_state["state"] == State.SELECTING_HAND.value
-
- # Compare sort_id ordering to make sure it's reversed
- initial_sort_ids = [joker["sort_id"] for joker in initial_jokers]
- final_sort_ids = [joker["sort_id"] for joker in final_state["jokers"]["cards"]]
- assert final_sort_ids == list(reversed(initial_sort_ids))
-
- def test_rearrange_jokers_noop(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sending indices in current order should leave the jokers unchanged."""
- initial_state = setup_and_teardown
- initial_jokers = initial_state["jokers"]["cards"]
- jokers_count: int = len(initial_jokers)
-
- # Existing order indices (0-based)
- current_order = list(range(jokers_count))
-
- final_state = send_and_receive_api_message(
- tcp_client,
- "rearrange_jokers",
- {"jokers": current_order},
- )
-
- assert final_state["state"] == State.SELECTING_HAND.value
-
- initial_sort_ids = [joker["sort_id"] for joker in initial_jokers]
- final_sort_ids = [joker["sort_id"] for joker in final_state["jokers"]["cards"]]
- assert final_sort_ids == initial_sort_ids
-
- # ------------------------------------------------------------------
- # Validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_rearrange_jokers_invalid_number_of_jokers(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing an index list with the wrong length should error."""
- jokers_count = len(setup_and_teardown["jokers"]["cards"])
- invalid_order = list(range(jokers_count - 1)) # one short
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_jokers",
- {"jokers": invalid_order},
- )
-
- assert_error_response(
- response,
- "Invalid number of jokers to rearrange",
- ["jokers_count", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_jokers_out_of_range_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Including an index >= jokers count should error."""
- jokers_count = len(setup_and_teardown["jokers"]["cards"])
- order = list(range(jokers_count))
- order[-1] = jokers_count # out-of-range zero-based index
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_jokers",
- {"jokers": order},
- )
-
- assert_error_response(
- response,
- "Joker index out of range",
- ["index", "max_index"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_rearrange_jokers_no_jokers_available(
- self, tcp_client: socket.socket
- ) -> None:
- """Calling rearrange_jokers when no jokers are available should error."""
- # Start a run without jokers (regular Red Deck without The Omelette challenge)
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "seed": "OOOO155",
- },
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_jokers",
- {"jokers": []},
- )
-
- assert_error_response(
- response,
- "No jokers available to rearrange",
- ["jokers_available"],
- ErrorCode.MISSING_GAME_OBJECT.value,
- )
-
- # Clean up
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_rearrange_jokers_missing_required_field(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Calling rearrange_jokers without the jokers field should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "rearrange_jokers",
- {}, # Missing required 'jokers' field
- )
-
- assert_error_response(
- response,
- "Missing required field: jokers",
- ["field"],
- ErrorCode.INVALID_PARAMETER.value,
- )
diff --git a/tests/lua/endpoints/test_reroll.py b/tests/lua/endpoints/test_reroll.py
new file mode 100644
index 0000000..c05faef
--- /dev/null
+++ b/tests/lua/endpoints/test_reroll.py
@@ -0,0 +1,66 @@
+"""Tests for src/lua/endpoints/reroll.lua"""
+
+import httpx
+import pytest
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestRerollEndpoint:
+ """Test basic reroll endpoint functionality."""
+
+ def test_reroll_from_shop(self, client: httpx.Client) -> None:
+ """Test rerolling shop from SHOP state."""
+ before = load_fixture(client, "reroll", "state-SHOP")
+ assert before["state"] == "SHOP"
+ response = api(client, "reroll", {})
+ after = assert_gamestate_response(response, state="SHOP")
+ assert before["shop"] != after["shop"]
+
+ def test_reroll_insufficient_funds(self, client: httpx.Client) -> None:
+ """Test reroll endpoint when player has insufficient funds."""
+ gamestate = load_fixture(client, "reroll", "state-SHOP--money-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["money"] == 0
+ assert_error_response(
+ api(client, "reroll", {}),
+ "NOT_ALLOWED",
+ "Not enough dollars to reroll",
+ )
+
+ def test_reroll_with_credit_card_joker(self, client: httpx.Client) -> None:
+ """Test rerolling when player has Credit Card joker (can go negative)."""
+ # Get to shop state with $0
+ gamestate = load_fixture(client, "reroll", "state-SHOP--money-0")
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["money"] == 0
+
+ # Add Credit Card joker (gives +$20 credit)
+ response = api(client, "add", {"key": "j_credit_card"})
+ gamestate = assert_gamestate_response(response)
+ assert any(j["key"] == "j_credit_card" for j in gamestate["jokers"]["cards"])
+
+ # Should be able to reroll (costs $5 by default) even with $0
+ response = api(client, "reroll", {})
+ gamestate = assert_gamestate_response(response)
+ # Money should be negative now
+ assert gamestate["money"] < 0
+
+
+class TestRerollEndpointStateRequirements:
+ """Test reroll endpoint state requirements."""
+
+ def test_reroll_from_BLIND_SELECT(self, client: httpx.Client):
+ """Test that reroll fails when not in SHOP state."""
+ gamestate = load_fixture(client, "reroll", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "reroll", {}),
+ "INVALID_STATE",
+ "Method 'reroll' requires one of these states: SHOP",
+ )
diff --git a/tests/lua/endpoints/test_save.py b/tests/lua/endpoints/test_save.py
new file mode 100644
index 0000000..9279917
--- /dev/null
+++ b/tests/lua/endpoints/test_save.py
@@ -0,0 +1,77 @@
+"""Tests for src/lua/endpoints/save.lua"""
+
+from pathlib import Path
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_path_response,
+ load_fixture,
+)
+
+
+class TestSaveEndpoint:
+ """Test basic save endpoint functionality."""
+
+ def test_save_from_BLIND_SELECT(self, client: httpx.Client, tmp_path: Path) -> None:
+ """Test that save succeeds from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "save", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ temp_file = tmp_path / "save"
+ response = api(client, "save", {"path": str(temp_file)})
+ assert_path_response(response)
+ assert response["result"]["path"] == str(temp_file)
+ assert temp_file.exists()
+ assert temp_file.stat().st_size > 0
+
+ def test_save_creates_valid_file(
+ self, client: httpx.Client, tmp_path: Path
+ ) -> None:
+ """Test that saved file can be loaded back successfully."""
+ gamestate = load_fixture(client, "save", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ temp_file = tmp_path / "save"
+ save_response = api(client, "save", {"path": str(temp_file)})
+ assert_path_response(save_response)
+ load_response = api(client, "load", {"path": str(temp_file)})
+ assert_path_response(load_response)
+
+
+class TestSaveValidation:
+ """Test save endpoint parameter validation."""
+
+ def test_missing_path_parameter(self, client: httpx.Client) -> None:
+ """Test that save fails when path parameter is missing."""
+ response = api(client, "save", {})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Missing required field 'path'",
+ )
+
+ def test_invalid_path_type(self, client: httpx.Client) -> None:
+ """Test that save fails when path is not a string."""
+ response = api(client, "save", {"path": 123})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'path' must be of type string",
+ )
+
+
+class TestSaveStateRequirements:
+ """Test save endpoint state requirements."""
+
+ def test_save_from_MENU(self, client: httpx.Client, tmp_path: Path) -> None:
+ """Test that save fails when not in an active run."""
+ api(client, "menu", {})
+ temp_file = tmp_path / "save"
+ response = api(client, "save", {"path": str(temp_file)})
+ assert_error_response(
+ response,
+ "INVALID_STATE",
+ "Method 'save' requires one of these states: SELECTING_HAND, HAND_PLAYED, DRAW_TO_HAND, GAME_OVER, SHOP, PLAY_TAROT, BLIND_SELECT, ROUND_EVAL, TAROT_PACK, PLANET_PACK, SPECTRAL_PACK, STANDARD_PACK, BUFFOON_PACK, NEW_ROUND",
+ )
+ assert not temp_file.exists()
diff --git a/tests/lua/endpoints/test_screenshot.py b/tests/lua/endpoints/test_screenshot.py
new file mode 100644
index 0000000..967bff7
--- /dev/null
+++ b/tests/lua/endpoints/test_screenshot.py
@@ -0,0 +1,65 @@
+"""Tests for src/lua/endpoints/screenshot.lua"""
+
+from pathlib import Path
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ assert_path_response,
+ load_fixture,
+)
+
+
+class TestScreenshotEndpoint:
+ """Test basic screenshot endpoint functionality."""
+
+ def test_screenshot_from_MENU(self, client: httpx.Client, tmp_path: Path) -> None:
+ """Test that screenshot succeeds from MENU state."""
+ gamestate = api(client, "menu", {})
+ assert_gamestate_response(gamestate, state="MENU")
+ temp_file = tmp_path / "screenshot.png"
+ response = api(client, "screenshot", {"path": str(temp_file)})
+ assert_path_response(response)
+ assert response["result"]["path"] == str(temp_file)
+ assert temp_file.exists()
+ assert temp_file.stat().st_size > 0
+ assert temp_file.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
+
+ def test_screenshot_from_BLIND_SELECT(
+ self, client: httpx.Client, tmp_path: Path
+ ) -> None:
+ """Test that screenshot succeeds from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "screenshot", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ temp_file = tmp_path / "screenshot.png"
+ response = api(client, "screenshot", {"path": str(temp_file)})
+ assert_path_response(response)
+ assert response["result"]["path"] == str(temp_file)
+ assert temp_file.exists()
+ assert temp_file.stat().st_size > 0
+ assert temp_file.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
+
+
+class TestScreenshotValidation:
+ """Test screenshot endpoint parameter validation."""
+
+ def test_missing_path_parameter(self, client: httpx.Client) -> None:
+ """Test that screenshot fails when path parameter is missing."""
+ response = api(client, "screenshot", {})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Missing required field 'path'",
+ )
+
+ def test_invalid_path_type(self, client: httpx.Client) -> None:
+ """Test that screenshot fails when path is not a string."""
+ response = api(client, "save", {"path": 123})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'path' must be of type string",
+ )
diff --git a/tests/lua/endpoints/test_select.py b/tests/lua/endpoints/test_select.py
new file mode 100644
index 0000000..64d2cce
--- /dev/null
+++ b/tests/lua/endpoints/test_select.py
@@ -0,0 +1,58 @@
+"""Tests for src/lua/endpoints/select.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestSelectEndpoint:
+ """Test basic select endpoint functionality."""
+
+ def test_select_small_blind(self, client: httpx.Client) -> None:
+ """Test selecting Small blind in BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "select", "state-BLIND_SELECT--blinds.small.status-SELECT"
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert gamestate["blinds"]["small"]["status"] == "SELECT"
+ response = api(client, "select", {})
+ assert_gamestate_response(response, state="SELECTING_HAND")
+
+ def test_select_big_blind(self, client: httpx.Client) -> None:
+ """Test selecting Big blind in BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "select", "state-BLIND_SELECT--blinds.big.status-SELECT"
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert gamestate["blinds"]["big"]["status"] == "SELECT"
+ response = api(client, "select", {})
+ assert_gamestate_response(response, state="SELECTING_HAND")
+
+ def test_select_boss_blind(self, client: httpx.Client) -> None:
+ """Test selecting Boss blind in BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "select", "state-BLIND_SELECT--blinds.boss.status-SELECT"
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert gamestate["blinds"]["boss"]["status"] == "SELECT"
+ response = api(client, "select", {})
+ assert_gamestate_response(response, state="SELECTING_HAND")
+
+
+class TestSelectEndpointStateRequirements:
+ """Test select endpoint state requirements."""
+
+ def test_select_from_MENU(self, client: httpx.Client):
+ """Test that select fails when not in BLIND_SELECT state."""
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
+ assert_error_response(
+ api(client, "select", {}),
+ "INVALID_STATE",
+ "Method 'select' requires one of these states: BLIND_SELECT",
+ )
diff --git a/tests/lua/endpoints/test_sell.py b/tests/lua/endpoints/test_sell.py
new file mode 100644
index 0000000..9090ae9
--- /dev/null
+++ b/tests/lua/endpoints/test_sell.py
@@ -0,0 +1,194 @@
+"""Tests for src/lua/endpoints/sell.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestSellEndpoint:
+ """Test basic sell endpoint functionality."""
+
+ def test_sell_no_args(self, client: httpx.Client) -> None:
+ """Test sell endpoint with no arguments."""
+ gamestate = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert_error_response(
+ api(client, "sell", {}),
+ "BAD_REQUEST",
+ "Must provide exactly one of: joker or consumable",
+ )
+
+ def test_sell_multi_args(self, client: httpx.Client) -> None:
+ """Test sell endpoint with multiple arguments."""
+ gamestate = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert_error_response(
+ api(client, "sell", {"joker": 0, "consumable": 0}),
+ "BAD_REQUEST",
+ "Can only sell one item at a time",
+ )
+
+ def test_sell_no_jokers(self, client: httpx.Client) -> None:
+ """Test sell endpoint when player has no jokers."""
+ gamestate = load_fixture(
+ client, "sell", "state-SELECTING_HAND--jokers.count-0--consumables.count-0"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["jokers"]["count"] == 0
+ assert_error_response(
+ api(client, "sell", {"joker": 0}),
+ "NOT_ALLOWED",
+ "No jokers available to sell",
+ )
+
+ def test_sell_no_consumables(self, client: httpx.Client) -> None:
+ """Test sell endpoint when player has no consumables."""
+ gamestate = load_fixture(
+ client, "sell", "state-SELECTING_HAND--jokers.count-0--consumables.count-0"
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["count"] == 0
+ assert_error_response(
+ api(client, "sell", {"consumable": 0}),
+ "NOT_ALLOWED",
+ "No consumables available to sell",
+ )
+
+ def test_sell_joker_invalid_index(self, client: httpx.Client) -> None:
+ """Test sell endpoint with invalid joker index."""
+ gamestate = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 1
+ assert_error_response(
+ api(client, "sell", {"joker": 1}),
+ "BAD_REQUEST",
+ "Index out of range for joker: 1",
+ )
+
+ def test_sell_consumable_invalid_index(self, client: httpx.Client) -> None:
+ """Test sell endpoint with invalid consumable index."""
+ gamestate = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 1
+ assert_error_response(
+ api(client, "sell", {"consumable": 1}),
+ "BAD_REQUEST",
+ "Index out of range for consumable: 1",
+ )
+
+ def test_sell_joker_in_SELECTING_HAND(self, client: httpx.Client) -> None:
+ """Test selling a joker in SELECTING_HAND state."""
+ before = load_fixture(
+ client,
+ "sell",
+ "state-SELECTING_HAND--jokers.count-1--consumables.count-1",
+ )
+ assert before["state"] == "SELECTING_HAND"
+ assert before["jokers"]["count"] == 1
+ response = api(client, "sell", {"joker": 0})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 0
+ assert before["money"] < after["money"]
+
+ def test_sell_consumable_in_SELECTING_HAND(self, client: httpx.Client) -> None:
+ """Test selling a consumable in SELECTING_HAND state."""
+ before = load_fixture(
+ client, "sell", "state-SELECTING_HAND--jokers.count-1--consumables.count-1"
+ )
+ assert before["state"] == "SELECTING_HAND"
+ assert before["consumables"]["count"] == 1
+ response = api(client, "sell", {"consumable": 0})
+ after = assert_gamestate_response(response)
+ assert after["consumables"]["count"] == 0
+ assert before["money"] < after["money"]
+
+ def test_sell_joker_in_SHOP(self, client: httpx.Client) -> None:
+ """Test selling a joker in SHOP state."""
+ before = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert before["state"] == "SHOP"
+ assert before["jokers"]["count"] == 1
+ response = api(client, "sell", {"joker": 0})
+ after = assert_gamestate_response(response)
+ assert after["jokers"]["count"] == 0
+ assert before["money"] < after["money"]
+
+ def test_sell_consumable_in_SHOP(self, client: httpx.Client) -> None:
+ """Test selling a consumable in SHOP state."""
+ before = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert before["state"] == "SHOP"
+ assert before["consumables"]["count"] == 1
+ response = api(client, "sell", {"consumable": 0})
+ after = assert_gamestate_response(response)
+ assert after["consumables"]["count"] == 0
+ assert before["money"] < after["money"]
+
+
+class TestSellEndpointValidation:
+ """Test sell endpoint parameter validation."""
+
+ def test_invalid_joker_type_string(self, client: httpx.Client) -> None:
+ """Test that sell fails when joker parameter is a string."""
+ gamestate = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["jokers"]["count"] == 1
+ assert_error_response(
+ api(client, "sell", {"joker": "INVALID_STRING"}),
+ "BAD_REQUEST",
+ "Field 'joker' must be an integer",
+ )
+
+ def test_invalid_consumable_type_string(self, client: httpx.Client) -> None:
+ """Test that sell fails when consumable parameter is a string."""
+ gamestate = load_fixture(
+ client, "sell", "state-SHOP--jokers.count-1--consumables.count-1"
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["count"] == 1
+ assert_error_response(
+ api(client, "sell", {"consumable": "INVALID_STRING"}),
+ "BAD_REQUEST",
+ "Field 'consumable' must be an integer",
+ )
+
+
+class TestSellEndpointStateRequirements:
+ """Test sell endpoint state requirements."""
+
+ def test_sell_from_BLIND_SELECT(self, client: httpx.Client) -> None:
+ """Test that sell fails from BLIND_SELECT state."""
+ gamestate = load_fixture(client, "sell", "state-BLIND_SELECT")
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "sell", {}),
+ "INVALID_STATE",
+ "Method 'sell' requires one of these states: SELECTING_HAND, SHOP",
+ )
+
+ def test_sell_from_ROUND_EVAL(self, client: httpx.Client) -> None:
+ """Test that sell fails from ROUND_EVAL state."""
+ gamestate = load_fixture(client, "sell", "state-ROUND_EVAL")
+ assert gamestate["state"] == "ROUND_EVAL"
+ assert_error_response(
+ api(client, "sell", {}),
+ "INVALID_STATE",
+ "Method 'sell' requires one of these states: SELECTING_HAND, SHOP",
+ )
diff --git a/tests/lua/endpoints/test_sell_consumable.py b/tests/lua/endpoints/test_sell_consumable.py
deleted file mode 100644
index d23905c..0000000
--- a/tests/lua/endpoints/test_sell_consumable.py
+++ /dev/null
@@ -1,238 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestSellConsumable:
- """Tests for the sell_consumable API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- """Start a run with consumables and yield initial state."""
- current_state = send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": "Bram Poker",
- "seed": "OOOO155",
- },
- )
-
- yield current_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Success scenario
- # ------------------------------------------------------------------
-
- def test_sell_consumable_success(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sell the first consumable and verify it's removed from the collection."""
- initial_state = setup_and_teardown
- initial_consumables = initial_state["consumables"]["cards"]
- initial_count = len(initial_consumables)
- initial_money = initial_state.get("game", {}).get("dollars", 0)
-
- # Get the consumable we're about to sell for reference
- consumable_to_sell = initial_consumables[0]
-
- # Sell the first consumable (index 0)
- final_state = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": 0},
- )
-
- # Verify consumable count decreased by 1
- final_consumables = final_state["consumables"]["cards"]
- assert len(final_consumables) == initial_count - 1
-
- # Verify the sold consumable is no longer in the collection
- final_sort_ids = [consumable["sort_id"] for consumable in final_consumables]
- assert consumable_to_sell["sort_id"] not in final_sort_ids
-
- # Verify money increased (consumables typically have sell value)
- final_money = final_state.get("game", {}).get("dollars", 0)
- assert final_money > initial_money
-
- def test_sell_consumable_last_consumable(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sell the last consumable by index and verify it's removed."""
- initial_state = setup_and_teardown
- initial_consumables = initial_state["consumables"]["cards"]
- initial_count = len(initial_consumables)
- last_index = initial_count - 1
-
- # Get the last consumable for reference
- consumable_to_sell = initial_consumables[last_index]
-
- # Sell the last consumable
- final_state = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": last_index},
- )
-
- # Verify consumable count decreased by 1
- final_consumables = final_state["consumables"]["cards"]
- assert len(final_consumables) == initial_count - 1
-
- # Verify the sold consumable is no longer in the collection
- final_sort_ids = [consumable["sort_id"] for consumable in final_consumables]
- assert consumable_to_sell["sort_id"] not in final_sort_ids
-
- def test_sell_consumable_multiple_sequential(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sell multiple consumables sequentially and verify each removal."""
- initial_state = setup_and_teardown
- initial_consumables = initial_state["consumables"]["cards"]
- initial_count = len(initial_consumables)
-
- # Skip if we don't have enough consumables for this test
- if initial_count < 2:
- pytest.skip("Need at least 2 consumables for sequential selling test")
-
- current_state = initial_state
-
- # Sell consumables one by one, always selling index 0
- for _ in range(2): # Sell 2 consumables
- current_consumables = current_state["consumables"]["cards"]
- consumable_to_sell = current_consumables[0]
-
- current_state = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": 0},
- )
-
- # Verify the consumable was removed
- remaining_consumables = current_state["consumables"]["cards"]
- remaining_sort_ids = [
- consumable["sort_id"] for consumable in remaining_consumables
- ]
- assert consumable_to_sell["sort_id"] not in remaining_sort_ids
- assert len(remaining_consumables) == len(current_consumables) - 1
-
- # Verify final count
- assert len(current_state["consumables"]["cards"]) == initial_count - 2
-
- # ------------------------------------------------------------------
- # Validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_sell_consumable_index_out_of_range_high(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing an index >= consumables count should error."""
- consumables_count = len(setup_and_teardown["consumables"]["cards"])
- invalid_index = consumables_count # out-of-range zero-based index
-
- response = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": invalid_index},
- )
-
- assert_error_response(
- response,
- "Consumable index out of range",
- ["index", "consumables_count"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_sell_consumable_negative_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing a negative index should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": -1},
- )
-
- assert_error_response(
- response,
- "Consumable index out of range",
- ["index", "consumables_count"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_sell_consumable_no_consumables_available(
- self, tcp_client: socket.socket
- ) -> None:
- """Calling sell_consumable when no consumables are available should error."""
- # Start a run without consumables (regular Red Deck without The Omelette challenge)
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "seed": "OOOO155",
- },
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- response = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": 0},
- )
-
- assert_error_response(
- response,
- "No consumables available to sell",
- ["consumables_available"],
- ErrorCode.MISSING_GAME_OBJECT.value,
- )
-
- # Clean up
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_sell_consumable_missing_required_field(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Calling sell_consumable without the index field should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {}, # Missing required 'index' field
- )
-
- assert_error_response(
- response,
- "Missing required field: index",
- ["field"],
- ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_sell_consumable_non_numeric_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing a non-numeric index should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "sell_consumable",
- {"index": "invalid"},
- )
-
- assert_error_response(
- response,
- "Invalid parameter type",
- ["parameter", "expected_type"],
- ErrorCode.INVALID_PARAMETER.value,
- )
diff --git a/tests/lua/endpoints/test_sell_joker.py b/tests/lua/endpoints/test_sell_joker.py
deleted file mode 100644
index 9819e73..0000000
--- a/tests/lua/endpoints/test_sell_joker.py
+++ /dev/null
@@ -1,277 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestSellJoker:
- """Tests for the sell_joker API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- """Start a run, reach SELECTING_HAND phase with jokers, yield initial state, then clean up."""
- # Begin a run with The Omelette challenge which starts with jokers
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": "The Omelette",
- "seed": "OOOO155",
- },
- )
-
- # Select blind to enter SELECTING_HAND state with jokers already available
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- assert game_state["state"] == State.SELECTING_HAND.value
-
- # Skip if we don't have any jokers to test with
- if (
- not game_state.get("jokers")
- or not game_state["jokers"].get("cards")
- or len(game_state["jokers"]["cards"]) < 1
- ):
- pytest.skip("No jokers available for testing sell_joker")
-
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Success scenario
- # ------------------------------------------------------------------
-
- def test_sell_joker_success(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sell the first joker and verify it's removed from the collection."""
- initial_state = setup_and_teardown
- initial_jokers = initial_state["jokers"]["cards"]
- initial_count = len(initial_jokers)
- initial_money = initial_state.get("dollars", 0)
-
- # Get the joker we're about to sell for reference
- joker_to_sell = initial_jokers[0]
-
- # Sell the first joker (index 0)
- final_state = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": 0},
- )
-
- # Ensure we remain in selecting hand state
- assert final_state["state"] == State.SELECTING_HAND.value
-
- # Verify joker count decreased by 1
- final_jokers = final_state["jokers"]["cards"]
- assert len(final_jokers) == initial_count - 1
-
- # Verify the sold joker is no longer in the collection
- final_sort_ids = [joker["sort_id"] for joker in final_jokers]
- assert joker_to_sell["sort_id"] not in final_sort_ids
-
- # Verify money increased (jokers typically have sell value)
- final_money = final_state.get("dollars", 0)
- assert final_money >= initial_money
-
- def test_sell_joker_last_joker(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sell the last joker by index and verify it's removed."""
- initial_state = setup_and_teardown
- initial_jokers = initial_state["jokers"]["cards"]
- initial_count = len(initial_jokers)
- last_index = initial_count - 1
-
- # Get the last joker for reference
- joker_to_sell = initial_jokers[last_index]
-
- # Sell the last joker
- final_state = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": last_index},
- )
-
- # Verify joker count decreased by 1
- final_jokers = final_state["jokers"]["cards"]
- assert len(final_jokers) == initial_count - 1
-
- # Verify the sold joker is no longer in the collection
- final_sort_ids = [joker["sort_id"] for joker in final_jokers]
- assert joker_to_sell["sort_id"] not in final_sort_ids
-
- def test_sell_joker_multiple_sequential(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Sell multiple jokers sequentially and verify each removal."""
- initial_state = setup_and_teardown
- initial_jokers = initial_state["jokers"]["cards"]
- initial_count = len(initial_jokers)
-
- # Skip if we don't have enough jokers for this test
- if initial_count < 2:
- pytest.skip("Need at least 2 jokers for sequential selling test")
-
- current_state = initial_state
-
- # Sell jokers one by one, always selling index 0
- for i in range(2): # Sell 2 jokers
- current_jokers = current_state["jokers"]["cards"]
- joker_to_sell = current_jokers[0]
-
- current_state = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": 0},
- )
-
- # Verify the joker was removed
- remaining_jokers = current_state["jokers"]["cards"]
- remaining_sort_ids = [joker["sort_id"] for joker in remaining_jokers]
- assert joker_to_sell["sort_id"] not in remaining_sort_ids
- assert len(remaining_jokers) == len(current_jokers) - 1
-
- # Verify final count
- assert len(current_state["jokers"]["cards"]) == initial_count - 2
-
- # ------------------------------------------------------------------
- # Validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_sell_joker_index_out_of_range_high(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing an index >= jokers count should error."""
- jokers_count = len(setup_and_teardown["jokers"]["cards"])
- invalid_index = jokers_count # out-of-range zero-based index
-
- response = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": invalid_index},
- )
-
- assert_error_response(
- response,
- "Joker index out of range",
- ["index", "jokers_count"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_sell_joker_negative_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing a negative index should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": -1},
- )
-
- assert_error_response(
- response,
- "Joker index out of range",
- ["index", "jokers_count"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_sell_joker_no_jokers_available(self, tcp_client: socket.socket) -> None:
- """Calling sell_joker when no jokers are available should error."""
- # Start a run without jokers (regular Red Deck without The Omelette challenge)
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "seed": "OOOO155",
- },
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- response = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": 0},
- )
-
- assert_error_response(
- response,
- "No jokers available to sell",
- ["jokers_available"],
- ErrorCode.MISSING_GAME_OBJECT.value,
- )
-
- # Clean up
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_sell_joker_missing_required_field(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Calling sell_joker without the index field should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {}, # Missing required 'index' field
- )
-
- assert_error_response(
- response,
- "Missing required field: index",
- ["field"],
- ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_sell_joker_non_numeric_index(
- self, tcp_client: socket.socket, setup_and_teardown: dict
- ) -> None:
- """Providing a non-numeric index should error."""
- response = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": "invalid"},
- )
-
- assert_error_response(
- response,
- "Invalid parameter type",
- ["parameter", "expected_type"],
- ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_sell_joker_unsellable_joker(self, tcp_client: socket.socket) -> None:
- """Attempting to sell an unsellable joker should error."""
-
- initial_state = send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": "Bram Poker", # contains an unsellable joker
- "seed": "OOOO155",
- },
- )
-
- assert len(initial_state["jokers"]["cards"]) == 1
-
- response = send_and_receive_api_message(
- tcp_client,
- "sell_joker",
- {"index": 0},
- )
-
- assert "cannot be sold" in response.get("error", "").lower()
diff --git a/tests/lua/endpoints/test_set.py b/tests/lua/endpoints/test_set.py
new file mode 100644
index 0000000..65fb900
--- /dev/null
+++ b/tests/lua/endpoints/test_set.py
@@ -0,0 +1,260 @@
+"""Tests for src/lua/endpoints/set.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestSetEndpoint:
+ """Test basic set endpoint functionality."""
+
+ def test_set_game_not_in_run(self, client: httpx.Client) -> None:
+ """Test that set fails when game is not in run."""
+ api(client, "menu", {})
+ response = api(client, "set", {})
+ assert_error_response(
+ response,
+ "INVALID_STATE",
+ "Can only set during an active run",
+ )
+
+ def test_set_no_fields(self, client: httpx.Client) -> None:
+ """Test that set fails when no fields are provided."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Must provide at least one field to set",
+ )
+
+ def test_set_negative_money(self, client: httpx.Client) -> None:
+ """Test that set fails when money is negative."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"money": -100})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Money must be a positive integer",
+ )
+
+ def test_set_money(self, client: httpx.Client) -> None:
+ """Test that set succeeds when money is positive."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"money": 100})
+ assert_gamestate_response(response, money=100)
+
+ def test_set_negative_chips(self, client: httpx.Client) -> None:
+ """Test that set fails when chips is negative."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"chips": -100})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Chips must be a positive integer",
+ )
+
+ def test_set_chips(self, client: httpx.Client) -> None:
+ """Test that set succeeds when chips is positive."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"chips": 100})
+ gamestate = assert_gamestate_response(response)
+ assert gamestate["round"]["chips"] == 100
+
+ def test_set_negative_ante(self, client: httpx.Client) -> None:
+ """Test that set fails when ante is negative."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"ante": -8})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Ante must be a positive integer",
+ )
+
+ def test_set_ante(self, client: httpx.Client) -> None:
+ """Test that set succeeds when ante is positive."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"ante": 8})
+ assert_gamestate_response(response, ante_num=8)
+
+ def test_set_negative_round(self, client: httpx.Client) -> None:
+ """Test that set fails when round is negative."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"round": -5})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Round must be a positive integer",
+ )
+
+ def test_set_round(self, client: httpx.Client) -> None:
+ """Test that set succeeds when round is positive."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"round": 5})
+ assert_gamestate_response(response, round_num=5)
+
+ def test_set_negative_hands(self, client: httpx.Client) -> None:
+ """Test that set fails when hands is negative."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"hands": -10})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Hands must be a positive integer",
+ )
+
+ def test_set_hands(self, client: httpx.Client) -> None:
+ """Test that set succeeds when hands is positive."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"hands": 10})
+ gamestate = assert_gamestate_response(response)
+ assert gamestate["round"]["hands_left"] == 10
+
+ def test_set_negative_discards(self, client: httpx.Client) -> None:
+ """Test that set fails when discards is negative."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"discards": -10})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Discards must be a positive integer",
+ )
+
+ def test_set_discards(self, client: httpx.Client) -> None:
+ """Test that set succeeds when discards is positive."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"discards": 10})
+ gamestate = assert_gamestate_response(response)
+ assert gamestate["round"]["discards_left"] == 10
+
+ def test_set_shop_from_selecting_hand(self, client: httpx.Client) -> None:
+ """Test that set fails when shop is called from SELECTING_HAND state."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"shop": True})
+ assert_error_response(
+ response,
+ "NOT_ALLOWED",
+ "Can re-stock shop only in SHOP state",
+ )
+
+ def test_set_shop_from_SHOP(self, client: httpx.Client) -> None:
+ """Test that set fails when shop is called from SHOP state."""
+ before = load_fixture(client, "set", "state-SHOP")
+ assert before["state"] == "SHOP"
+ response = api(client, "set", {"shop": True})
+ after = assert_gamestate_response(response)
+ assert len(after["shop"]["cards"]) > 0
+ assert len(before["shop"]["cards"]) > 0
+ assert after["shop"] != before["shop"]
+ assert after["packs"] != before["packs"]
+ assert after["vouchers"] != before["vouchers"] # here only the id is changed
+
+ def test_set_shop_set_round_set_money(self, client: httpx.Client) -> None:
+ """Test that set fails when shop is called from SHOP state."""
+ before = load_fixture(client, "set", "state-SHOP")
+ assert before["state"] == "SHOP"
+ response = api(client, "set", {"shop": True, "round": 5, "money": 100})
+ after = assert_gamestate_response(response, round_num=5, money=100)
+ assert after["shop"] != before["shop"]
+ assert after["packs"] != before["packs"]
+ assert after["vouchers"] != before["vouchers"] # here only the id is changed
+
+
+class TestSetEndpointValidation:
+ """Test set endpoint parameter validation."""
+
+ def test_invalid_money_type(self, client: httpx.Client):
+ """Test that set fails when money parameter is not an integer."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"money": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'money' must be an integer",
+ )
+
+ def test_invalid_chips_type(self, client: httpx.Client):
+ """Test that set fails when chips parameter is not an integer."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"chips": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'chips' must be an integer",
+ )
+
+ def test_invalid_ante_type(self, client: httpx.Client):
+ """Test that set fails when ante parameter is not an integer."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"ante": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'ante' must be an integer",
+ )
+
+ def test_invalid_round_type(self, client: httpx.Client):
+ """Test that set fails when round parameter is not an integer."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"round": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'round' must be an integer",
+ )
+
+ def test_invalid_hands_type(self, client: httpx.Client):
+ """Test that set fails when hands parameter is not an integer."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"hands": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'hands' must be an integer",
+ )
+
+ def test_invalid_discards_type(self, client: httpx.Client):
+ """Test that set fails when discards parameter is not an integer."""
+ gamestate = load_fixture(client, "set", "state-SELECTING_HAND")
+ assert gamestate["state"] == "SELECTING_HAND"
+ response = api(client, "set", {"discards": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'discards' must be an integer",
+ )
+
+ def test_invalid_shop_type(self, client: httpx.Client):
+ """Test that set fails when shop parameter is not a boolean."""
+ gamestate = load_fixture(client, "set", "state-SHOP")
+ assert gamestate["state"] == "SHOP"
+ response = api(client, "set", {"shop": "INVALID_STRING"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'shop' must be of type boolean",
+ )
diff --git a/tests/lua/endpoints/test_shop.py b/tests/lua/endpoints/test_shop.py
deleted file mode 100644
index 7ad8977..0000000
--- a/tests/lua/endpoints/test_shop.py
+++ /dev/null
@@ -1,582 +0,0 @@
-import socket
-from pathlib import Path
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import (
- assert_error_response,
- prepare_checkpoint,
- send_and_receive_api_message,
-)
-
-
-class TestShop:
- """Tests for the shop API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Set up and tear down each test method."""
- # Load checkpoint that already has the game in shop state
- checkpoint_path = Path(__file__).parent / "checkpoints" / "basic_shop_setup.jkr"
-
- game_state = prepare_checkpoint(tcp_client, checkpoint_path)
- # time.sleep(0.5)
- assert game_state["state"] == State.SHOP.value
-
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_shop_next_round_success(self, tcp_client: socket.socket) -> None:
- """Test successful shop next_round action transitions to blind select."""
- # Execute next_round action
- game_state = send_and_receive_api_message(
- tcp_client, "shop", {"action": "next_round"}
- )
-
- # Verify we're in blind select state after next_round
- assert game_state["state"] == State.BLIND_SELECT.value
-
- def test_shop_invalid_action_error(self, tcp_client: socket.socket) -> None:
- """Test shop returns error for invalid action."""
- # Try invalid action
- response = send_and_receive_api_message(
- tcp_client, "shop", {"action": "invalid_action"}
- )
-
- # Verify error response
- assert_error_response(
- response,
- "Invalid action for shop",
- ["action"],
- ErrorCode.INVALID_ACTION.value,
- )
-
- def test_shop_jokers_structure(self, tcp_client: socket.socket) -> None:
- """Test that shop_jokers contains expected structure when in shop state."""
- # Get current game state while in shop
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
-
- # Verify we're in shop state
- assert game_state["state"] == State.SHOP.value
-
- # Verify shop_jokers exists and has correct structure
- assert "shop_jokers" in game_state
- shop_jokers = game_state["shop_jokers"]
-
- # Verify top-level structure
- assert "cards" in shop_jokers
- assert "config" in shop_jokers
- assert isinstance(shop_jokers["cards"], list)
- assert isinstance(shop_jokers["config"], dict)
-
- # Verify config structure
- config = shop_jokers["config"]
- assert "card_count" in config
- assert "card_limit" in config
- assert isinstance(config["card_count"], int)
- assert isinstance(config["card_limit"], int)
-
- # Verify each card has required fields
- for card in shop_jokers["cards"]:
- assert "ability" in card
- assert "config" in card
- assert "cost" in card
- assert "debuff" in card
- assert "facing" in card
- # TODO: Use traditional method for checking shop structure.
- # TODO: continuing a run causes the highlighted field to be vacant
- # TODO: this does not prevent the cards from being selected, seems to be a quirk of balatro.
- # assert "highlighted" in card
- assert "label" in card
- assert "sell_cost" in card
-
- # Verify card config has center_key
- assert "center_key" in card["config"]
- assert isinstance(card["config"]["center_key"], str)
-
- # Verify ability has set field
- assert "set" in card["ability"]
- assert isinstance(card["ability"]["set"], str)
-
- # Verify we have expected cards from the reference game state
- center_key = [card["config"]["center_key"] for card in shop_jokers["cards"]]
- card_labels = [card["label"] for card in shop_jokers["cards"]]
-
- # Should contain Burglar joker and Jupiter planet card based on reference
- assert "j_burglar" in center_key
- assert "c_jupiter" in center_key
- assert "Burglar" in card_labels
- assert "Jupiter" in card_labels
-
- def test_shop_vouchers_structure(self, tcp_client: socket.socket) -> None:
- """Test that shop_vouchers contains expected structure when in shop state."""
- # Get current game state while in shop
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
-
- # Verify we're in shop state
- assert game_state["state"] == State.SHOP.value
-
- # Verify shop_vouchers exists and has correct structure
- assert "shop_vouchers" in game_state
- shop_vouchers = game_state["shop_vouchers"]
-
- # Verify top-level structure
- assert "cards" in shop_vouchers
- assert "config" in shop_vouchers
- assert isinstance(shop_vouchers["cards"], list)
- assert isinstance(shop_vouchers["config"], dict)
-
- # Verify config structure
- config = shop_vouchers["config"]
- assert "card_count" in config
- assert "card_limit" in config
- assert isinstance(config["card_count"], int)
- assert isinstance(config["card_limit"], int)
-
- # Verify each voucher card has required fields
- for card in shop_vouchers["cards"]:
- assert "ability" in card
- assert "config" in card
- assert "cost" in card
- assert "debuff" in card
- assert "facing" in card
- # TODO: Use traditional method for checking shop structure.
- # TODO: continuing a run causes the highlighted field to be vacant
- # TODO: this does not prevent the cards from being selected, seems to be a quirk of balatro.
- # assert "highlighted" in card
- assert "label" in card
- assert "sell_cost" in card
-
- # Verify card config has center_key (vouchers use center_key not card_key)
- assert "center_key" in card["config"]
- assert isinstance(card["config"]["center_key"], str)
-
- # Verify ability has set field with "Voucher" value
- assert "set" in card["ability"]
- assert card["ability"]["set"] == "Voucher"
-
- # Verify we have expected voucher from the reference game state
- center_keys = [card["config"]["center_key"] for card in shop_vouchers["cards"]]
- card_labels = [card["label"] for card in shop_vouchers["cards"]]
-
- # Should contain Hone voucher based on reference
- assert "v_hone" in center_keys
- assert "Hone" in card_labels
-
- def test_shop_booster_structure(self, tcp_client: socket.socket) -> None:
- """Test that shop_booster contains expected structure when in shop state."""
- # Get current game state while in shop
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
-
- # Verify we're in shop state
- assert game_state["state"] == State.SHOP.value
-
- # Verify shop_booster exists and has correct structure
- assert "shop_booster" in game_state
- shop_booster = game_state["shop_booster"]
-
- # Verify top-level structure
- assert "cards" in shop_booster
- assert "config" in shop_booster
- assert isinstance(shop_booster["cards"], list)
- assert isinstance(shop_booster["config"], dict)
-
- # Verify config structure
- config = shop_booster["config"]
- assert "card_count" in config
- assert "card_limit" in config
- assert isinstance(config["card_count"], int)
- assert isinstance(config["card_limit"], int)
-
- # Verify each booster card has required fields
- for card in shop_booster["cards"]:
- assert "ability" in card
- assert "config" in card
- assert "cost" in card
- # TODO: Use traditional method for checking shop structure.
- # TODO: continuing a run causes the highlighted field to be vacant
- # TODO: this does not prevent the cards from being selected, seems to be a quirk of balatro.
- # assert "highlighted" in card
- assert "label" in card
- assert "sell_cost" in card
-
- # Verify card config has center_key
- assert "center_key" in card["config"]
- assert isinstance(card["config"]["center_key"], str)
-
- # Verify ability has set field with "Booster" value
- assert "set" in card["ability"]
- assert card["ability"]["set"] == "Booster"
-
- # Verify we have expected booster packs from the reference game state
- center_keys = [card["config"]["center_key"] for card in shop_booster["cards"]]
- card_labels = [card["label"] for card in shop_booster["cards"]]
-
- # Should contain Buffoon Pack and Jumbo Buffoon Pack based on reference
- assert "p_buffoon_normal_1" in center_keys
- assert "p_buffoon_jumbo_1" in center_keys
- assert "Buffoon Pack" in card_labels
- assert "Jumbo Buffoon Pack" in card_labels
-
- def test_shop_buy_card(self, tcp_client: socket.socket) -> None:
- """Test buying a card from the shop."""
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert game_state["state"] == State.SHOP.value
- assert game_state["shop_jokers"]["cards"][0]["cost"] == 6
- assert game_state["game"]["dollars"] == 10
- # Buy the burglar
- purchase_response = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": 0},
- )
- assert purchase_response["state"] == State.SHOP.value
- assert purchase_response["shop_jokers"]["cards"][0]["cost"] == 3
- assert purchase_response["game"]["dollars"] == 4
- assert purchase_response["jokers"]["cards"][0]["cost"] == 6
-
- # ------------------------------------------------------------------
- # reroll shop
- # ------------------------------------------------------------------
-
- def test_shop_reroll_success(self, tcp_client: socket.socket) -> None:
- """Successful reroll keeps us in shop and updates cards / dollars."""
-
- # Capture shop state before reroll
- before_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert before_state["state"] == State.SHOP.value
- before_keys = [
- c["config"]["center_key"] for c in before_state["shop_jokers"]["cards"]
- ]
- dollars_before = before_state["game"]["dollars"]
- reroll_cost = before_state["game"]["current_round"]["reroll_cost"]
-
- # Perform the reroll
- after_state = send_and_receive_api_message(
- tcp_client, "shop", {"action": "reroll"}
- )
-
- # verify state
- assert after_state["state"] == State.SHOP.value
- assert after_state["game"]["dollars"] == dollars_before - reroll_cost
- after_keys = [
- c["config"]["center_key"] for c in after_state["shop_jokers"]["cards"]
- ]
- assert before_keys != after_keys
-
- def test_shop_reroll_insufficient_dollars(self, tcp_client: socket.socket) -> None:
- """Repeated rerolls eventually raise INVALID_ACTION when too expensive."""
-
- # Perform rerolls until an error is returned or a reasonable max tries reached
- max_attempts = 10
- for _ in range(max_attempts):
- response = send_and_receive_api_message(
- tcp_client, "shop", {"action": "reroll"}
- )
-
- # Break when error encountered and validate
- if "error" in response:
- assert_error_response(
- response,
- "Not enough dollars to reroll",
- ["dollars", "reroll_cost"],
- ErrorCode.INVALID_ACTION.value,
- )
- break
- else:
- pytest.fail("Rerolls did not exhaust dollars within expected attempts")
-
- # ------------------------------------------------------------------
- # buy_card validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_buy_card_missing_index(self, tcp_client: socket.socket) -> None:
- """Missing index for buy_card should raise INVALID_PARAMETER."""
- response = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card"},
- )
-
- assert_error_response(
- response,
- "Missing required field: index",
- ["field"],
- ErrorCode.MISSING_ARGUMENTS.value,
- )
-
- def test_buy_card_index_out_of_range(self, tcp_client: socket.socket) -> None:
- """Index >= len(shop_jokers.cards) should raise PARAMETER_OUT_OF_RANGE."""
- # Fetch current shop state to know max index
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert game_state["state"] == State.SHOP.value
-
- out_of_range_index = len(game_state["shop_jokers"]["cards"])
- response = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": out_of_range_index},
- )
- assert_error_response(
- response,
- "Card index out of range",
- ["index", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_buy_card_not_affordable(self, tcp_client: socket.socket) -> None:
- """Index >= len(shop_jokers.cards) should raise PARAMETER_OUT_OF_RANGE."""
- # Fetch current shop state to know max index
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {"deck": "Red Deck", "stake": 1, "seed": "OOOO155"},
- )
- send_and_receive_api_message(
- tcp_client,
- "skip_or_select_blind",
- {"action": "select"},
- )
- # Get to shop with fewer than 9 dollars so planet cannot be afforded
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [5]},
- )
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [5]},
- )
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [2, 3, 4, 5]}, # 2 aces are drawn
- )
- send_and_receive_api_message(tcp_client, "cash_out", {})
-
- # Buy the burglar
- send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": 0},
- )
- # Fail to buy the jupiter
- game_state = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": 0},
- )
- assert_error_response(
- game_state,
- "Card is not affordable",
- ["index", "cost", "dollars"],
- ErrorCode.INVALID_ACTION.value,
- )
-
- def test_shop_invalid_state_error(self, tcp_client: socket.socket) -> None:
- """Test shop returns error when not in shop state."""
- # Go to menu first to ensure we're not in shop state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # Try to use shop when not in shop state - should return error
- response = send_and_receive_api_message(
- tcp_client, "shop", {"action": "next_round"}
- )
-
- # Verify error response
- assert_error_response(
- response,
- "Cannot select shop action when not in shop",
- ["current_state"],
- ErrorCode.INVALID_GAME_STATE.value,
- )
-
- def test_redeem_voucher_success(self, tcp_client: socket.socket) -> None:
- """Redeem the first voucher successfully and verify effects."""
- # Capture shop state before redemption
- before_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert before_state["state"] == State.SHOP.value
- assert "shop_vouchers" in before_state
- assert before_state["shop_vouchers"]["cards"], "No vouchers available to redeem"
-
- voucher_cost = before_state["shop_vouchers"]["cards"][0]["cost"]
- dollars_before = before_state["game"]["dollars"]
- discount_before = before_state["game"].get("discount_percent", 0)
-
- # Redeem the voucher at index 0
- after_state = send_and_receive_api_message(
- tcp_client, "shop", {"action": "redeem_voucher", "index": 0}
- )
-
- # Verify we remain in shop state
- assert after_state["state"] == State.SHOP.value
-
- # Dollar count should decrease by voucher cost (cost may be 0 for free vouchers)
- assert after_state["game"]["dollars"] == dollars_before - voucher_cost
-
- # Discount percent should not decrease; usually increases after redeem
- assert after_state["game"].get("discount_percent", 0) >= discount_before
-
- def test_redeem_voucher_missing_index(self, tcp_client: socket.socket) -> None:
- """Missing index for redeem_voucher should raise INVALID_PARAMETER."""
- response = send_and_receive_api_message(
- tcp_client, "shop", {"action": "redeem_voucher"}
- )
- assert_error_response(
- response,
- "Missing required field: index",
- ["field"],
- ErrorCode.MISSING_ARGUMENTS.value,
- )
-
- def test_redeem_voucher_index_out_of_range(self, tcp_client: socket.socket) -> None:
- """Index >= len(shop_vouchers.cards) should raise PARAMETER_OUT_OF_RANGE."""
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert game_state["state"] == State.SHOP.value
- out_of_range_index = len(game_state["shop_vouchers"]["cards"])
-
- response = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "redeem_voucher", "index": out_of_range_index},
- )
- assert_error_response(
- response,
- "Voucher index out of range",
- ["index", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- # ------------------------------------------------------------------
- # buy_and_use_card
- # ------------------------------------------------------------------
-
- def test_buy_and_use_card_success(self, tcp_client: socket.socket) -> None:
- """Buy-and-use a consumable card directly from the shop."""
-
- def _consumables_count(state: dict) -> int:
- consumables = state.get("consumeables") or {}
- return len(consumables.get("cards", []) or [])
-
- before_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert before_state["state"] == State.SHOP.value
-
- # Find a consumable in shop_jokers (Planet/Tarot/Spectral)
- idx = None
- cost = None
- for i, card in enumerate(before_state["shop_jokers"]["cards"]):
- if card["ability"]["set"] in {"Planet", "Tarot", "Spectral"}:
- idx = i
- cost = card["cost"]
- break
-
- if idx is None:
- pytest.skip("No consumable available in shop to buy_and_use for this seed")
-
- dollars_before = before_state["game"]["dollars"]
- consumables_before = _consumables_count(before_state)
-
- after_state = send_and_receive_api_message(
- tcp_client, "shop", {"action": "buy_and_use_card", "index": idx}
- )
-
- assert after_state["state"] == State.SHOP.value
- assert after_state["game"]["dollars"] == dollars_before - cost
- # Using directly should not add to consumables area
- assert _consumables_count(after_state) == consumables_before
-
- def test_buy_and_use_card_missing_index(self, tcp_client: socket.socket) -> None:
- """Missing index for buy_and_use_card should raise INVALID_PARAMETER."""
- response = send_and_receive_api_message(
- tcp_client, "shop", {"action": "buy_and_use_card"}
- )
- assert_error_response(
- response,
- "Missing required field: index",
- ["field"],
- ErrorCode.MISSING_ARGUMENTS.value,
- )
-
- def test_buy_and_use_card_index_out_of_range(
- self, tcp_client: socket.socket
- ) -> None:
- """Index >= len(shop_jokers.cards) should raise PARAMETER_OUT_OF_RANGE."""
- game_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- assert game_state["state"] == State.SHOP.value
-
- out_of_range_index = len(game_state["shop_jokers"]["cards"])
- response = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_and_use_card", "index": out_of_range_index},
- )
- assert_error_response(
- response,
- "Card index out of range",
- ["index", "valid_range"],
- ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_buy_and_use_card_not_affordable(self, tcp_client: socket.socket) -> None:
- """Attempting to buy_and_use a consumable more expensive than current dollars should error."""
- # Reduce dollars first by buying a cheap joker
-
- _ = send_and_receive_api_message(
- tcp_client, "shop", {"action": "redeem_voucher", "index": 0}
- )
-
- mid_state = send_and_receive_api_message(tcp_client, "get_game_state", {})
- dollars_now = mid_state["game"]["dollars"]
-
- # Find a consumable still in the shop with cost greater than current dollars
- idx = None
- for i, card in enumerate(mid_state["shop_jokers"]["cards"]):
- if (
- card["ability"]["set"] in {"Planet", "Tarot", "Spectral"}
- and card["cost"] > dollars_now
- ):
- idx = i
- break
-
- if idx is None:
- pytest.skip(
- "No unaffordable consumable found to test buy_and_use_card error path"
- )
-
- response = send_and_receive_api_message(
- tcp_client, "shop", {"action": "buy_and_use_card", "index": idx}
- )
- assert_error_response(
- response,
- "Card is not affordable",
- ["index", "cost", "dollars"],
- ErrorCode.INVALID_ACTION.value,
- )
-
- # ------------------------------------------------------------------
- # New test: buy_and_use unavailable despite being a consumable
- # ------------------------------------------------------------------
-
- def test_buy_and_use_card_button_missing(self, tcp_client: socket.socket) -> None:
- """Use a checkpoint where a consumable cannot be bought-and-used and assert proper error."""
- checkpoint_path = Path(__file__).parent / "checkpoints" / "buy_cant_use.jkr"
- game_state = prepare_checkpoint(tcp_client, checkpoint_path)
- assert game_state["state"] == State.SHOP.value
-
- response = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_and_use_card", "index": 1},
- )
- assert_error_response(
- response,
- "Consumable cannot be used at this time",
- ["index"],
- ErrorCode.INVALID_ACTION.value,
- )
diff --git a/tests/lua/endpoints/test_skip.py b/tests/lua/endpoints/test_skip.py
new file mode 100644
index 0000000..5a89edc
--- /dev/null
+++ b/tests/lua/endpoints/test_skip.py
@@ -0,0 +1,65 @@
+"""Tests for src/lua/endpoints/skip.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestSkipEndpoint:
+ """Test basic skip endpoint functionality."""
+
+ def test_skip_small_blind(self, client: httpx.Client) -> None:
+ """Test skipping Small blind in BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "skip", "state-BLIND_SELECT--blinds.small.status-SELECT"
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert gamestate["blinds"]["small"]["status"] == "SELECT"
+ response = api(client, "skip", {})
+ gamestate = assert_gamestate_response(response, state="BLIND_SELECT")
+ assert gamestate["blinds"]["small"]["status"] == "SKIPPED"
+ assert gamestate["blinds"]["big"]["status"] == "SELECT"
+
+ def test_skip_big_blind(self, client: httpx.Client) -> None:
+ """Test skipping Big blind in BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "skip", "state-BLIND_SELECT--blinds.big.status-SELECT"
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert gamestate["blinds"]["big"]["status"] == "SELECT"
+ response = api(client, "skip", {})
+ gamestate = assert_gamestate_response(response, state="BLIND_SELECT")
+ assert gamestate["blinds"]["big"]["status"] == "SKIPPED"
+ assert gamestate["blinds"]["boss"]["status"] == "SELECT"
+
+ def test_skip_big_boss(self, client: httpx.Client) -> None:
+ """Test skipping Boss in BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client, "skip", "state-BLIND_SELECT--blinds.boss.status-SELECT"
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert gamestate["blinds"]["boss"]["status"] == "SELECT"
+ assert_error_response(
+ api(client, "skip", {}),
+ "NOT_ALLOWED",
+ "Cannot skip Boss blind",
+ )
+
+
+class TestSkipEndpointStateRequirements:
+ """Test skip endpoint state requirements."""
+
+ def test_skip_from_MENU(self, client: httpx.Client):
+ """Test that skip fails when not in BLIND_SELECT state."""
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
+ assert_error_response(
+ api(client, "skip", {}),
+ "INVALID_STATE",
+ "Method 'skip' requires one of these states: BLIND_SELECT",
+ )
diff --git a/tests/lua/endpoints/test_skip_or_select_blind.py b/tests/lua/endpoints/test_skip_or_select_blind.py
deleted file mode 100644
index 6f54c23..0000000
--- a/tests/lua/endpoints/test_skip_or_select_blind.py
+++ /dev/null
@@ -1,230 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestSkipOrSelectBlind:
- """Tests for the skip_or_select_blind API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Set up and tear down each test method."""
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "OOOO155",
- }
- game_state = send_and_receive_api_message(
- tcp_client, "start_run", start_run_args
- )
- assert game_state["state"] == State.BLIND_SELECT.value
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_select_blind(self, tcp_client: socket.socket) -> None:
- """Test selecting a blind during the blind selection phase."""
- # Select the blind
- select_blind_args = {"action": "select"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", select_blind_args
- )
-
- # Verify we get a valid game state response
- assert game_state["state"] == State.SELECTING_HAND.value
-
- # Assert that there are 8 cards in the hand
- assert len(game_state["hand"]["cards"]) == 8
-
- def test_skip_blind(self, tcp_client: socket.socket) -> None:
- """Test skipping a blind during the blind selection phase."""
- # Skip the blind
- skip_blind_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_blind_args
- )
-
- # Verify we get a valid game state response
- assert game_state["state"] == State.BLIND_SELECT.value
-
- # Assert that the current blind is "Big", the "Small" blind was skipped
- assert game_state["game"]["blind_on_deck"] == "Big"
-
- def test_skip_big_blind(self, tcp_client: socket.socket) -> None:
- """Test complete flow: play small blind, cash out, skip shop, skip big blind."""
- # 1. Play small blind (select it)
- select_blind_args = {"action": "select"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", select_blind_args
- )
-
- # Verify we're in hand selection state
- assert game_state["state"] == State.SELECTING_HAND.value
-
- # 2. Play winning hand (four of a kind)
- play_hand_args = {"action": "play_hand", "cards": [0, 1, 2, 3]}
- game_state = send_and_receive_api_message(
- tcp_client, "play_hand_or_discard", play_hand_args
- )
-
- # Verify we're in round evaluation state
- assert game_state["state"] == State.ROUND_EVAL.value
-
- # 3. Cash out to go to shop
- game_state = send_and_receive_api_message(tcp_client, "cash_out", {})
-
- # Verify we're in shop state
- assert game_state["state"] == State.SHOP.value
-
- # 4. Skip shop (next round)
- game_state = send_and_receive_api_message(
- tcp_client, "shop", {"action": "next_round"}
- )
-
- # Verify we're back in blind selection state
- assert game_state["state"] == State.BLIND_SELECT.value
-
- # 5. Skip the big blind
- skip_big_blind_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_big_blind_args
- )
-
- # Verify we successfully skipped the big blind and are still in blind selection
- assert game_state["state"] == State.BLIND_SELECT.value
-
- def test_skip_both_blinds(self, tcp_client: socket.socket) -> None:
- """Test skipping small blind then immediately skipping big blind."""
- # 1. Skip the small blind
- skip_small_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_small_args
- )
-
- # Verify we're still in blind selection and the big blind is on deck
- assert game_state["state"] == State.BLIND_SELECT.value
- assert game_state["game"]["blind_on_deck"] == "Big"
-
- # 2. Skip the big blind
- skip_big_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_big_args
- )
-
- # Verify we successfully skipped both blinds
- assert game_state["state"] == State.BLIND_SELECT.value
-
- def test_invalid_blind_action(self, tcp_client: socket.socket) -> None:
- """Test that invalid blind action arguments are handled properly."""
- # Should receive error response
- error_response = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "invalid_action"}
- )
-
- # Verify error response
- assert_error_response(
- error_response,
- "Invalid action for skip_or_select_blind",
- ["action"],
- ErrorCode.INVALID_ACTION.value,
- )
-
- def test_skip_or_select_blind_invalid_state(
- self, tcp_client: socket.socket
- ) -> None:
- """Test that skip_or_select_blind returns error when not in blind selection state."""
- # Go to menu to ensure we're not in blind selection state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # Try to select blind when not in blind selection state
- error_response = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- # Verify error response
- assert_error_response(
- error_response,
- "Cannot skip or select blind when not in blind selection",
- ["current_state"],
- ErrorCode.INVALID_GAME_STATE.value,
- )
-
- def test_boss_blind_skip_prevention(self, tcp_client: socket.socket) -> None:
- """Test that trying to skip a Boss blind returns INVALID_PARAMETER error."""
- # Skip small blind to reach big blind
- skip_small_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_small_args
- )
- assert game_state["game"]["blind_on_deck"] == "Big"
-
- # Skip big blind to reach boss blind
- skip_big_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_big_args
- )
- assert game_state["game"]["blind_on_deck"] == "Boss"
-
- # Try to skip boss blind - should return error
- skip_boss_args = {"action": "skip"}
- error_response = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_boss_args
- )
-
- # Verify error response
- assert_error_response(
- error_response,
- "Cannot skip Boss blind. Use select instead",
- ["current_state"],
- ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_boss_blind_select_still_works(self, tcp_client: socket.socket) -> None:
- """Test that selecting a Boss blind still works correctly."""
- # Skip small blind to reach big blind
- skip_small_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_small_args
- )
- assert game_state["game"]["blind_on_deck"] == "Big"
-
- # Skip big blind to reach boss blind
- skip_big_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_big_args
- )
- assert game_state["game"]["blind_on_deck"] == "Boss"
-
- # Select boss blind - should work successfully
- select_boss_args = {"action": "select"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", select_boss_args
- )
-
- # Verify we successfully selected the boss blind and transitioned to hand selection
- assert game_state["state"] == State.SELECTING_HAND.value
-
- def test_non_boss_blind_skip_still_works(self, tcp_client: socket.socket) -> None:
- """Test that skipping Small and Big blinds still works correctly."""
- # Skip small blind - should work fine
- skip_small_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_small_args
- )
- assert game_state["state"] == State.BLIND_SELECT.value
- assert game_state["game"]["blind_on_deck"] == "Big"
-
- # Skip big blind - should also work fine
- skip_big_args = {"action": "skip"}
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", skip_big_args
- )
- assert game_state["state"] == State.BLIND_SELECT.value
- assert game_state["game"]["blind_on_deck"] == "Boss"
diff --git a/tests/lua/endpoints/test_start.py b/tests/lua/endpoints/test_start.py
new file mode 100644
index 0000000..7502475
--- /dev/null
+++ b/tests/lua/endpoints/test_start.py
@@ -0,0 +1,164 @@
+"""Tests for the start endpoint."""
+
+from typing import Any
+
+import httpx
+import pytest
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestStartEndpoint:
+ """Parametrized tests for the start endpoint."""
+
+ @pytest.mark.parametrize(
+ "arguments,expected",
+ [
+ # Test basic start with RED deck and WHITE stake
+ (
+ {"deck": "RED", "stake": "WHITE"},
+ {
+ "state": "BLIND_SELECT",
+ "deck": "RED",
+ "stake": "WHITE",
+ "ante_num": 1,
+ "round_num": 0,
+ },
+ ),
+ # Test with BLUE deck
+ (
+ {"deck": "BLUE", "stake": "WHITE"},
+ {
+ "state": "BLIND_SELECT",
+ "deck": "BLUE",
+ "stake": "WHITE",
+ "ante_num": 1,
+ "round_num": 0,
+ },
+ ),
+ # Test with higher stake (BLACK)
+ (
+ {"deck": "RED", "stake": "BLACK"},
+ {
+ "state": "BLIND_SELECT",
+ "deck": "RED",
+ "stake": "BLACK",
+ "ante_num": 1,
+ "round_num": 0,
+ },
+ ),
+ # Test with seed
+ (
+ {"deck": "RED", "stake": "WHITE", "seed": "TEST123"},
+ {
+ "state": "BLIND_SELECT",
+ "deck": "RED",
+ "stake": "WHITE",
+ "ante_num": 1,
+ "round_num": 0,
+ "seed": "TEST123",
+ },
+ ),
+ ],
+ )
+ def test_start_from_MENU(
+ self,
+ client: httpx.Client,
+ arguments: dict[str, Any],
+ expected: dict[str, Any],
+ ):
+ """Test start endpoint with various valid parameters."""
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
+ response = api(client, "start", arguments)
+ assert_gamestate_response(response, **expected)
+
+
+class TestStartEndpointValidation:
+ """Test start endpoint parameter validation."""
+
+ 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"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Missing required field 'deck'",
+ )
+
+ 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"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Missing required field 'stake'",
+ )
+
+ def test_invalid_deck_value(self, client: httpx.Client):
+ """Test that start fails with invalid deck enum."""
+ response = api(client, "menu", {})
+ assert_gamestate_response(response, state="MENU")
+ response = api(client, "start", {"deck": "INVALID_DECK", "stake": "WHITE"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Invalid deck enum. Must be one of:",
+ )
+
+ 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"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Invalid stake enum. Must be one of:",
+ )
+
+ 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"})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'deck' must be of type string",
+ )
+
+ 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})
+ assert_error_response(
+ response,
+ "BAD_REQUEST",
+ "Field 'stake' must be of type string",
+ )
+
+
+class TestStartEndpointStateRequirements:
+ """Test start endpoint state requirements."""
+
+ 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"})
+ assert_error_response(
+ response,
+ "INVALID_STATE",
+ "Method 'start' requires one of these states: MENU",
+ )
diff --git a/tests/lua/endpoints/test_start_run.py b/tests/lua/endpoints/test_start_run.py
deleted file mode 100644
index 4e9c707..0000000
--- a/tests/lua/endpoints/test_start_run.py
+++ /dev/null
@@ -1,100 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestStartRun:
- """Tests for the start_run API endpoint."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Set up and tear down each test method."""
- yield
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_start_run(self, tcp_client: socket.socket) -> None:
- """Test starting a run and verifying the state."""
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- game_state = send_and_receive_api_message(
- tcp_client, "start_run", start_run_args
- )
-
- assert game_state["state"] == State.BLIND_SELECT.value
-
- def test_start_run_with_challenge(self, tcp_client: socket.socket) -> None:
- """Test starting a run with a challenge."""
- start_run_args = {
- "deck": "Red Deck",
- "stake": 1,
- "challenge": "The Omelette",
- "seed": "EXAMPLE",
- }
- game_state = send_and_receive_api_message(
- tcp_client, "start_run", start_run_args
- )
- assert game_state["state"] == State.BLIND_SELECT.value
- assert (
- len(game_state["jokers"]["cards"]) == 5
- ) # jokers in The Omelette challenge
-
- def test_start_run_different_stakes(self, tcp_client: socket.socket) -> None:
- """Test starting runs with different stake levels."""
- for stake in [1, 2, 3]:
- start_run_args = {
- "deck": "Red Deck",
- "stake": stake,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- game_state = send_and_receive_api_message(
- tcp_client, "start_run", start_run_args
- )
-
- assert game_state["state"] == State.BLIND_SELECT.value
-
- # Go back to menu for next iteration
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_start_run_missing_required_args(self, tcp_client: socket.socket) -> None:
- """Test start_run with missing required arguments."""
- # Missing deck
- incomplete_args = {
- "stake": 1,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- # Should receive error response
- response = send_and_receive_api_message(
- tcp_client, "start_run", incomplete_args
- )
- assert_error_response(
- response,
- "Missing required field: deck",
- expected_error_code=ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_start_run_invalid_deck(self, tcp_client: socket.socket) -> None:
- """Test start_run with invalid deck name."""
- invalid_args = {
- "deck": "Nonexistent Deck",
- "stake": 1,
- "challenge": None,
- "seed": "EXAMPLE",
- }
- # Should receive error response
- response = send_and_receive_api_message(tcp_client, "start_run", invalid_args)
- assert_error_response(
- response, "Invalid deck name", ["deck"], ErrorCode.DECK_NOT_FOUND.value
- )
diff --git a/tests/lua/endpoints/test_use.py b/tests/lua/endpoints/test_use.py
new file mode 100644
index 0000000..997edb9
--- /dev/null
+++ b/tests/lua/endpoints/test_use.py
@@ -0,0 +1,359 @@
+"""Tests for src/lua/endpoints/use.lua"""
+
+import httpx
+
+from tests.lua.conftest import (
+ api,
+ assert_error_response,
+ assert_gamestate_response,
+ load_fixture,
+)
+
+
+class TestUseEndpoint:
+ """Test basic use endpoint functionality."""
+
+ def test_use_hermit_no_cards(self, client: httpx.Client) -> None:
+ """Test using The Hermit (no card selection) in SHOP state."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SHOP--money-12--consumables.cards[0]-key-c_hermit",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["money"] == 12
+ assert gamestate["consumables"]["cards"][0]["key"] == "c_hermit"
+ response = api(client, "use", {"consumable": 0})
+ assert_gamestate_response(response, money=24)
+
+ def test_use_hermit_in_selecting_hand(self, client: httpx.Client) -> None:
+ """Test using The Hermit in SELECTING_HAND state."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--money-12--consumables.cards[0]-key-c_hermit",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["money"] == 12
+ assert gamestate["consumables"]["cards"][0]["key"] == "c_hermit"
+ response = api(client, "use", {"consumable": 0})
+ assert_gamestate_response(response, money=24)
+
+ def test_use_temperance_no_cards(self, client: httpx.Client) -> None:
+ """Test using Temperance (no card selection)."""
+ before = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0]-key-c_temperance--jokers.count-0",
+ )
+ assert before["state"] == "SELECTING_HAND"
+ assert before["jokers"]["count"] == 0 # no jokers => no money increase
+ assert before["consumables"]["cards"][0]["key"] == "c_temperance"
+ response = api(client, "use", {"consumable": 0})
+ assert_gamestate_response(response, money=before["money"])
+
+ def test_use_planet_no_cards(self, client: httpx.Client) -> None:
+ """Test using a Planet card (no card selection)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["hands"]["High Card"]["level"] == 1
+ response = api(client, "use", {"consumable": 0})
+ after = assert_gamestate_response(response)
+ assert after["hands"]["High Card"]["level"] == 2
+
+ def test_use_magician_with_one_card(self, client: httpx.Client) -> None:
+ """Test using The Magician with 1 card (min=1, max=2)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ 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"
+
+ def test_use_magician_with_two_cards(self, client: httpx.Client) -> None:
+ """Test using The Magician with 2 cards."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ 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"
+
+ def test_use_familiar_all_hand(self, client: httpx.Client) -> None:
+ """Test using Familiar (destroys cards, #G.hand.cards > 1)."""
+ before = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0]-key-c_familiar",
+ )
+ assert before["state"] == "SELECTING_HAND"
+ response = api(client, "use", {"consumable": 0})
+ after = assert_gamestate_response(response)
+ assert after["hand"]["count"] == before["hand"]["count"] - 1 + 3
+ assert after["hand"]["cards"][7]["set"] == "ENHANCED"
+ assert after["hand"]["cards"][8]["set"] == "ENHANCED"
+ assert after["hand"]["cards"][9]["set"] == "ENHANCED"
+
+
+class TestUseEndpointValidation:
+ """Test use endpoint parameter validation."""
+
+ def test_use_no_consumable_provided(self, client: httpx.Client) -> None:
+ """Test that use fails when consumable parameter is missing."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {}),
+ "BAD_REQUEST",
+ "Missing required field 'consumable'",
+ )
+
+ def test_use_invalid_consumable_type(self, client: httpx.Client) -> None:
+ """Test that use fails when consumable is not an integer."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": "NOT_AN_INTEGER"}),
+ "BAD_REQUEST",
+ "Field 'consumable' must be an integer",
+ )
+
+ def test_use_invalid_consumable_index_negative(self, client: httpx.Client) -> None:
+ """Test that use fails when consumable index is negative."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": -1}),
+ "BAD_REQUEST",
+ "Consumable index out of range: -1",
+ )
+
+ def test_use_invalid_consumable_index_too_high(self, client: httpx.Client) -> None:
+ """Test that use fails when consumable index >= count."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": 999}),
+ "BAD_REQUEST",
+ "Consumable index out of range: 999",
+ )
+
+ def test_use_invalid_cards_type(self, client: httpx.Client) -> None:
+ """Test that use fails when cards is not an array."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": 1, "cards": "NOT_AN_ARRAY_OF_INTEGERS"}),
+ "BAD_REQUEST",
+ "Field 'cards' must be an array",
+ )
+
+ def test_use_invalid_cards_item_type(self, client: httpx.Client) -> None:
+ """Test that use fails when cards array contains non-integer."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": 1, "cards": ["NOT_INT_1", "NOT_INT_2"]}),
+ "BAD_REQUEST",
+ "Field 'cards' array item at index 0 must be of type integer",
+ )
+
+ def test_use_invalid_card_index_negative(self, client: httpx.Client) -> None:
+ """Test that use fails when a card index is negative."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": 1, "cards": [-1]}),
+ "BAD_REQUEST",
+ "Card index out of range: -1",
+ )
+
+ def test_use_invalid_card_index_too_high(self, client: httpx.Client) -> None:
+ """Test that use fails when a card index >= hand count."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert_error_response(
+ api(client, "use", {"consumable": 1, "cards": [999]}),
+ "BAD_REQUEST",
+ "Card index out of range: 999",
+ )
+
+ def test_use_magician_without_cards(self, client: httpx.Client) -> None:
+ """Test that using The Magician without cards parameter fails."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["cards"][1]["key"] == "c_magician"
+ assert_error_response(
+ api(client, "use", {"consumable": 1}),
+ "BAD_REQUEST",
+ "Consumable 'The Magician' requires card selection",
+ )
+
+ def test_use_magician_with_empty_cards(self, client: httpx.Client) -> None:
+ """Test that using The Magician with empty cards array fails."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["cards"][1]["key"] == "c_magician"
+ assert_error_response(
+ api(client, "use", {"consumable": 1, "cards": []}),
+ "BAD_REQUEST",
+ "Consumable 'The Magician' requires card selection",
+ )
+
+ def test_use_magician_too_many_cards(self, client: httpx.Client) -> None:
+ """Test that using The Magician with 3 cards fails (max=2)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_pluto--consumables.cards[1].key-c_magician",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["cards"][1]["key"] == "c_magician"
+ assert_error_response(
+ api(client, "use", {"consumable": 1, "cards": [0, 1, 2]}),
+ "BAD_REQUEST",
+ "Consumable 'The Magician' requires at most 2 cards (provided: 3)",
+ )
+
+ def test_use_death_too_few_cards(self, client: httpx.Client) -> None:
+ """Test that using Death with 1 card fails (requires exactly 2)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_death",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["cards"][0]["key"] == "c_death"
+ assert_error_response(
+ api(client, "use", {"consumable": 0, "cards": [0]}),
+ "BAD_REQUEST",
+ "Consumable 'Death' requires exactly 2 cards (provided: 1)",
+ )
+
+ def test_use_death_too_many_cards(self, client: httpx.Client) -> None:
+ """Test that using Death with 3 cards fails (requires exactly 2)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SELECTING_HAND--consumables.cards[0].key-c_death",
+ )
+ assert gamestate["state"] == "SELECTING_HAND"
+ assert gamestate["consumables"]["cards"][0]["key"] == "c_death"
+ assert_error_response(
+ api(client, "use", {"consumable": 0, "cards": [0, 1, 2]}),
+ "BAD_REQUEST",
+ "Consumable 'Death' requires exactly 2 cards (provided: 3)",
+ )
+
+
+class TestUseEndpointStateRequirements:
+ """Test use endpoint state requirements."""
+
+ def test_use_from_BLIND_SELECT(self, client: httpx.Client) -> None:
+ """Test that use fails from BLIND_SELECT state."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-BLIND_SELECT",
+ )
+ assert gamestate["state"] == "BLIND_SELECT"
+ assert_error_response(
+ api(client, "use", {"consumable": 0, "cards": [0]}),
+ "INVALID_STATE",
+ "Method 'use' requires one of these states: SELECTING_HAND, SHOP",
+ )
+
+ def test_use_from_ROUND_EVAL(self, client: httpx.Client) -> None:
+ """Test that use fails from ROUND_EVAL state."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-ROUND_EVAL",
+ )
+ assert gamestate["state"] == "ROUND_EVAL"
+ assert_error_response(
+ api(client, "use", {"consumable": 0, "cards": [0]}),
+ "INVALID_STATE",
+ "Method 'use' requires one of these states: SELECTING_HAND, SHOP",
+ )
+
+ def test_use_magician_from_SHOP(self, client: httpx.Client) -> None:
+ """Test that using The Magician fails from SHOP (needs SELECTING_HAND)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SHOP--consumables.cards[0].key-c_magician",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["cards"][0]["key"] == "c_magician"
+ assert_error_response(
+ api(client, "use", {"consumable": 0, "cards": [0]}),
+ "INVALID_STATE",
+ "Consumable 'The Magician' requires card selection and can only be used in SELECTING_HAND state",
+ )
+
+ def test_use_familiar_from_SHOP(self, client: httpx.Client) -> None:
+ """Test that using The Magician fails from SHOP (needs SELECTING_HAND)."""
+ gamestate = load_fixture(
+ client,
+ "use",
+ "state-SHOP--consumables.cards[0]-key-c_familiar",
+ )
+ assert gamestate["state"] == "SHOP"
+ assert gamestate["consumables"]["cards"][0]["key"] == "c_familiar"
+ assert_error_response(
+ api(client, "use", {"consumable": 0}),
+ "NOT_ALLOWED",
+ "Consumable 'Familiar' cannot be used at this time",
+ )
diff --git a/tests/lua/endpoints/test_use_consumable.py b/tests/lua/endpoints/test_use_consumable.py
deleted file mode 100644
index 55d2465..0000000
--- a/tests/lua/endpoints/test_use_consumable.py
+++ /dev/null
@@ -1,411 +0,0 @@
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode, State
-
-from ..conftest import assert_error_response, send_and_receive_api_message
-
-
-class TestUseConsumablePlanet:
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "seed": "OOOO155",
- },
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
- send_and_receive_api_message(tcp_client, "cash_out", {})
- game_state = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": 1},
- )
-
- assert game_state["state"] == State.SHOP.value
- # we are expecting to have a planet card in the consumables
-
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- # ------------------------------------------------------------------
- # Success scenario
- # ------------------------------------------------------------------
-
- def test_use_consumable_planet_success(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test successfully using a planet consumable."""
- game_state = setup_and_teardown
-
- # Verify we have a consumable (planet) in slot 0
- assert len(game_state["consumables"]["cards"]) > 0
-
- # Use the first consumable (index 0)
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": 0}
- )
-
- # Verify the consumable was used (should be removed from consumables)
- assert response["state"] == State.SHOP.value
- # The consumable should be consumed and removed
- assert (
- len(response["consumables"]["cards"])
- == len(game_state["consumables"]["cards"]) - 1
- )
-
- # ------------------------------------------------------------------
- # Validation / error scenarios
- # ------------------------------------------------------------------
-
- def test_use_consumable_invalid_index(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test using consumable with invalid index."""
- game_state = setup_and_teardown
- consumables_count = len(game_state["consumables"]["cards"])
-
- # Test with index out of range
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": consumables_count}
- )
- assert_error_response(
- response,
- "Consumable index out of range",
- expected_error_code=ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_use_consumable_missing_index(
- self,
- tcp_client: socket.socket,
- ) -> None:
- """Test using consumable without providing index."""
- response = send_and_receive_api_message(tcp_client, "use_consumable", {})
- assert_error_response(
- response,
- "Missing required field",
- expected_error_code=ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_use_consumable_invalid_index_type(
- self,
- tcp_client: socket.socket,
- ) -> None:
- """Test using consumable with non-numeric index."""
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": "invalid"}
- )
- assert_error_response(
- response,
- "Invalid parameter type",
- expected_error_code=ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_use_consumable_negative_index(
- self,
- tcp_client: socket.socket,
- ) -> None:
- """Test using consumable with negative index."""
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": -1}
- )
- assert_error_response(
- response,
- "Consumable index out of range",
- expected_error_code=ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_use_consumable_float_index(
- self,
- tcp_client: socket.socket,
- ) -> None:
- """Test using consumable with float index."""
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": 1.5}
- )
- assert_error_response(
- response,
- "Invalid parameter type",
- expected_error_code=ErrorCode.INVALID_PARAMETER.value,
- )
-
-
-class TestUseConsumableNoConsumables:
- """Test use_consumable when no consumables are available."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- # Start a run but don't buy any consumables
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "seed": "OOOO155",
- },
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
- game_state = send_and_receive_api_message(tcp_client, "cash_out", {})
-
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_use_consumable_no_consumables_available(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test using consumable when no consumables are available."""
- game_state = setup_and_teardown
-
- # Verify no consumables are available
- assert len(game_state["consumables"]["cards"]) == 0
-
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": 0}
- )
- assert_error_response(
- response,
- "No consumables available to use",
- expected_error_code=ErrorCode.MISSING_GAME_OBJECT.value,
- )
-
-
-class TestUseConsumableWithCards:
- """Test use_consumable with cards parameter for consumables that target specific cards."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[dict, None, None]:
- # Start a run and get to SELECTING_HAND state with a consumable
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {
- "deck": "Red Deck",
- "stake": 1,
- "seed": "TEST123",
- },
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- # Play a hand to get to shop
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
- send_and_receive_api_message(tcp_client, "cash_out", {})
-
- # Buy a consumable
- send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": 2},
- )
-
- # Start next round to get back to SELECTING_HAND state
- send_and_receive_api_message(tcp_client, "shop", {"action": "next_round"})
- game_state = send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
-
- yield game_state
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
-
- def test_use_consumable_with_cards_success(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test successfully using a consumable with specific cards selected."""
- game_state = setup_and_teardown
-
- # Verify we're in SELECTING_HAND state
- assert game_state["state"] == State.SELECTING_HAND.value
-
- # Skip test if no consumables available
- if len(game_state["consumables"]["cards"]) == 0:
- pytest.skip("No consumables available in this test run")
-
- # Use the consumable with specific cards selected
- response = send_and_receive_api_message(
- tcp_client,
- "use_consumable",
- {"index": 0, "cards": [0, 2, 4]}, # Select cards 0, 2, and 4
- )
-
- # Verify response is successful
- assert "error" not in response
-
- def test_use_consumable_with_invalid_cards(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test using consumable with invalid card indices."""
- game_state = setup_and_teardown
-
- # Skip test if no consumables available
- if len(game_state["consumables"]["cards"]) == 0:
- pytest.skip("No consumables available in this test run")
-
- # Try to use consumable with out-of-range card indices
- response = send_and_receive_api_message(
- tcp_client,
- "use_consumable",
- {"index": 0, "cards": [99, 100]}, # Invalid card indices
- )
- assert_error_response(
- response,
- "Invalid card index",
- expected_error_code=ErrorCode.INVALID_CARD_INDEX.value,
- )
-
- def test_use_consumable_with_too_many_cards(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test using consumable with more than 5 cards."""
- game_state = setup_and_teardown
-
- # Skip test if no consumables available
- if len(game_state["consumables"]["cards"]) == 0:
- pytest.skip("No consumables available in this test run")
-
- # Try to use consumable with more than 5 cards
- response = send_and_receive_api_message(
- tcp_client,
- "use_consumable",
- {"index": 0, "cards": [0, 1, 2, 3, 4, 5]}, # 6 cards - too many
- )
- assert_error_response(
- response,
- "Invalid number of cards",
- expected_error_code=ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_use_consumable_with_empty_cards(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test using consumable with empty cards array."""
- game_state = setup_and_teardown
-
- # Skip test if no consumables available
- if len(game_state["consumables"]["cards"]) == 0:
- pytest.skip("No consumables available in this test run")
-
- # Try to use consumable with empty cards array
- response = send_and_receive_api_message(
- tcp_client,
- "use_consumable",
- {"index": 0, "cards": []}, # Empty array
- )
- assert_error_response(
- response,
- "Invalid number of cards",
- expected_error_code=ErrorCode.PARAMETER_OUT_OF_RANGE.value,
- )
-
- def test_use_consumable_with_invalid_cards_type(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test using consumable with non-array cards parameter."""
- game_state = setup_and_teardown
-
- # Skip test if no consumables available
- if len(game_state["consumables"]["cards"]) == 0:
- pytest.skip("No consumables available in this test run")
-
- # Try to use consumable with invalid cards type
- response = send_and_receive_api_message(
- tcp_client,
- "use_consumable",
- {"index": 0, "cards": "invalid"}, # Not an array
- )
- assert_error_response(
- response,
- "Invalid parameter type",
- expected_error_code=ErrorCode.INVALID_PARAMETER.value,
- )
-
- def test_use_planet_without_cards(
- self, tcp_client: socket.socket, setup_and_teardown
- ) -> None:
- """Test that planet consumables still work without cards parameter."""
- game_state = setup_and_teardown
-
- # Skip test if no consumables available
- if len(game_state["consumables"]["cards"]) == 0:
- pytest.skip("No consumables available in this test run")
-
- # Use consumable without cards parameter (original behavior)
- response = send_and_receive_api_message(
- tcp_client,
- "use_consumable",
- {"index": 0}, # No cards parameter
- )
-
- # Should still work for consumables that don't need cards
- assert "error" not in response
-
- def test_use_consumable_with_cards_wrong_state(
- self, tcp_client: socket.socket
- ) -> None:
- """Test that using consumable with cards fails in non-SELECTING_HAND states."""
- # Start a run and get to shop state
- send_and_receive_api_message(
- tcp_client,
- "start_run",
- {"deck": "Red Deck", "stake": 1, "seed": "OOOO155"},
- )
- send_and_receive_api_message(
- tcp_client, "skip_or_select_blind", {"action": "select"}
- )
- send_and_receive_api_message(
- tcp_client,
- "play_hand_or_discard",
- {"action": "play_hand", "cards": [0, 1, 2, 3]},
- )
- send_and_receive_api_message(tcp_client, "cash_out", {})
- game_state = send_and_receive_api_message(
- tcp_client,
- "shop",
- {"action": "buy_card", "index": 1},
- )
-
- # Verify we're in SHOP state
- assert game_state["state"] == State.SHOP.value
-
- # Try to use consumable with cards while in SHOP state (should fail)
- response = send_and_receive_api_message(
- tcp_client, "use_consumable", {"index": 0, "cards": [0, 1, 2]}
- )
- assert_error_response(
- response,
- "Cannot use consumable with cards when not in selecting hand state",
- expected_error_code=ErrorCode.INVALID_GAME_STATE.value,
- )
-
- send_and_receive_api_message(tcp_client, "go_to_menu", {})
diff --git a/tests/lua/test_connection.py b/tests/lua/test_connection.py
deleted file mode 100644
index 18c684b..0000000
--- a/tests/lua/test_connection.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""Tests for BalatroBot TCP API connection and protocol handling."""
-
-import json
-import socket
-
-import pytest
-
-from .conftest import HOST, assert_error_response, receive_api_message, send_api_message
-
-
-def test_basic_connection(tcp_client: socket.socket) -> None:
- """Test basic TCP connection and response."""
- send_api_message(tcp_client, "get_game_state", {})
-
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
-
-
-def test_rapid_messages(tcp_client: socket.socket) -> None:
- """Test rapid succession of get_game_state messages."""
- responses = []
-
- for _ in range(3):
- send_api_message(tcp_client, "get_game_state", {})
- game_state = receive_api_message(tcp_client)
- responses.append(game_state)
-
- assert all(isinstance(resp, dict) for resp in responses)
- assert len(responses) == 3
-
-
-def test_connection_timeout() -> None:
- """Test behavior when no server is listening."""
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.settimeout(0.2)
-
- with pytest.raises((socket.timeout, ConnectionRefusedError)):
- sock.connect((HOST, 12345)) # Unused port
-
-
-def test_invalid_json_message(tcp_client: socket.socket) -> None:
- """Test that invalid JSON messages return error responses."""
- # Send invalid JSON
- tcp_client.send(b"invalid json\n")
-
- # Should receive error response for invalid JSON
- error_response = receive_api_message(tcp_client)
- assert_error_response(error_response, "Invalid JSON")
-
- # Verify server is still responsive
- send_api_message(tcp_client, "get_game_state", {})
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
-
-
-def test_missing_name_field(tcp_client: socket.socket) -> None:
- """Test message without name field returns error response."""
- message = {"arguments": {}}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- # Should receive error response for missing name field
- error_response = receive_api_message(tcp_client)
- assert_error_response(error_response, "Message must contain a name")
-
- # Verify server is still responsive
- send_api_message(tcp_client, "get_game_state", {})
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
-
-
-def test_missing_arguments_field(tcp_client: socket.socket) -> None:
- """Test message without arguments field returns error response."""
- message = {"name": "get_game_state"}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- # Should receive error response for missing arguments field
- error_response = receive_api_message(tcp_client)
- assert_error_response(error_response, "Message must contain arguments")
-
- # Verify server is still responsive
- send_api_message(tcp_client, "get_game_state", {})
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
-
-
-def test_unknown_message(tcp_client: socket.socket) -> None:
- """Test that unknown messages return error responses."""
- # Send unknown message
- send_api_message(tcp_client, "unknown_function", {})
-
- # Should receive error response for unknown function
- error_response = receive_api_message(tcp_client)
- assert_error_response(error_response, "Unknown function name", ["name"])
-
- # Verify server is still responsive
- send_api_message(tcp_client, "get_game_state", {})
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
-
-
-def test_large_message_handling(tcp_client: socket.socket) -> None:
- """Test handling of large messages within TCP limits."""
- # Create a large but valid message
- large_args = {"data": "x" * 1000} # 1KB of data
- send_api_message(tcp_client, "get_game_state", large_args)
-
- # Should still get a response
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
-
-
-def test_empty_message(tcp_client: socket.socket) -> None:
- """Test sending an empty message."""
- tcp_client.send(b"\n")
-
- # Verify server is still responsive
- send_api_message(tcp_client, "get_game_state", {})
- game_state = receive_api_message(tcp_client)
- assert isinstance(game_state, dict)
diff --git a/tests/lua/test_log.py b/tests/lua/test_log.py
deleted file mode 100644
index 0e2874b..0000000
--- a/tests/lua/test_log.py
+++ /dev/null
@@ -1,207 +0,0 @@
-"""Tests logging of game states to JSONL files."""
-
-import copy
-import json
-import time
-from pathlib import Path
-from typing import Any
-
-import pytest
-from deepdiff import DeepDiff
-
-from balatrobot.client import BalatroClient
-
-
-def get_jsonl_files() -> list[Path]:
- """Get all JSONL files from the runs directory."""
- runs_dir = Path(__file__).parent.parent / "runs"
- return list(runs_dir.glob("*.jsonl"))
-
-
-def load_jsonl_run(file_path: Path) -> list[dict[str, Any]]:
- """Load a JSONL file and return list of run steps."""
- steps = []
- with open(file_path, "r") as f:
- for line in f:
- line = line.strip()
- if line: # Skip empty lines
- steps.append(json.loads(line))
- return steps
-
-
-def normalize_step(step: dict[str, Any]) -> dict[str, Any]:
- """Normalize a step by removing non-deterministic fields."""
- normalized = copy.deepcopy(step)
-
- # Remove timestamp as it's non-deterministic
- normalized.pop("timestamp_ms_before", None)
- normalized.pop("timestamp_ms_after", None)
-
- # Remove log_path from start_run function arguments as it's non-deterministic
- if "function" in normalized and normalized["function"]["name"] == "start_run":
- if "arguments" in normalized["function"]:
- normalized["function"]["arguments"].pop("log_path", None)
-
- # Remove non-deterministic fields from game states
- for state_key in ["game_state_before", "game_state_after"]:
- if state_key in normalized:
- game_state = normalized[state_key]
- if "hand" in game_state and "cards" in game_state["hand"]:
- for card in game_state["hand"]["cards"]:
- card.pop("highlighted", None)
- card.pop("sort_id", None)
- if "jokers" in game_state and "cards" in game_state["jokers"]:
- for card in game_state["jokers"]["cards"]:
- card.pop("highlighted", None)
- card.pop("sort_id", None)
- if "consumables" in game_state and "cards" in game_state["consumables"]:
- for card in game_state["consumables"]["cards"]:
- card.pop("highlighted", None)
- card.pop("sort_id", None)
- if "game" in game_state and "smods_version" in game_state["game"]:
- game_state["game"].pop("smods_version", None)
-
- # we don't care about the game_state_before when starting a run
- if step.get("function", {}).get("name") == "start_run":
- normalized.pop("game_state_before", None)
-
- return normalized
-
-
-def assert_steps_equal(
- actual: dict[str, Any], expected: dict[str, Any], context: str = ""
-):
- """Assert two steps are equal with clear diff output."""
- normalized_actual = normalize_step(actual)
- normalized_expected = normalize_step(expected)
-
- diff = DeepDiff(
- normalized_actual,
- normalized_expected,
- ignore_order=True,
- verbose_level=2,
- )
-
- if diff:
- error_msg = "Steps are not equal"
- if context:
- error_msg += f" ({context})"
- error_msg += f"\n\n{diff.pretty()}"
- pytest.fail(error_msg)
-
-
-class TestLog:
- """Tests for the log module."""
-
- @pytest.fixture(scope="session", params=get_jsonl_files(), ids=lambda p: p.name)
- def replay_logs(self, request, tmp_path_factory) -> tuple[Path, Path, Path]:
- """Fixture that replays a run and generates two JSONL log files.
-
- Returns:
- Tuple of (original_jsonl_path, lua_generated_path, python_generated_path)
- """
- original_jsonl: Path = request.param
-
- # Create temporary file paths
- tmp_path = tmp_path_factory.mktemp("replay_logs")
- base_name = original_jsonl.stem
- lua_log_path = tmp_path / f"{base_name}_lua.jsonl"
- python_log_path = tmp_path / f"{base_name}_python.jsonl"
-
- print(
- "\nJSONL files:\n"
- f"- original: {original_jsonl}\n"
- f"- lua: {lua_log_path}\n"
- f"- python: {python_log_path}\n"
- )
-
- # Load original steps
- original_steps = load_jsonl_run(original_jsonl)
-
- with BalatroClient() as client:
- # Initialize game state
- current_state = client.send_message("go_to_menu", {})
-
- python_log_entries = []
-
- # Process all steps
- for step in original_steps:
- function_call = step["function"]
-
- # The current state becomes the "before" state for this function call
- game_state_before = current_state
-
- # For start_run, we need to add the log_path parameter to trigger Lua logging
- if function_call["name"] == "start_run":
- call_args = function_call["arguments"].copy()
- call_args["log_path"] = str(lua_log_path)
- else:
- call_args = function_call["arguments"]
-
- # Create Python log entry
- log_entry = {
- "function": function_call,
- "timestamp_ms_before": int(time.time_ns() // 1_000_000),
- "game_state_before": game_state_before,
- }
-
- current_state = client.send_message(function_call["name"], call_args)
-
- # Update the log entry with after function call info
- log_entry["timestamp_ms_after"] = int(time.time_ns() // 1_000_000)
- log_entry["game_state_after"] = current_state
-
- python_log_entries.append(log_entry)
-
- # Write Python log file
- with open(python_log_path, "w") as f:
- for entry in python_log_entries:
- f.write(json.dumps(entry, sort_keys=True) + "\n")
-
- return original_jsonl, lua_log_path, python_log_path
-
- def test_compare_lua_logs_with_original_run(
- self, replay_logs: tuple[Path, Path, Path]
- ) -> None:
- """Test that Lua-generated and Python-generated logs are equivalent.
-
- This test the log file "writing" (lua_log_path) and compare with the
- original jsonl file (original_jsonl).
- """
- original_jsonl, lua_log_path, _ = replay_logs
-
- # Load both generated log files
- lua_steps = load_jsonl_run(lua_log_path)
- orig_steps = load_jsonl_run(original_jsonl)
-
- assert len(lua_steps) == len(orig_steps), (
- f"Different number of steps: Lua={len(lua_steps)}, Python={len(orig_steps)}"
- )
-
- # Compare each step
- for i, (original_step, lua_step) in enumerate(zip(orig_steps, lua_steps)):
- context = f"step {i} in {original_jsonl.name} (Origianl vs Lua logs)"
- assert_steps_equal(lua_step, original_step, context)
-
- def test_compare_python_logs_with_original_run(
- self, replay_logs: tuple[Path, Path, Path]
- ) -> None:
- """Test that generated logs match the original run game states.
-
- This test the log file "reading" (original_jsonl) and test the ability
- to replicate the run (python_log_path).
- """
- original_jsonl, _, python_log_path = replay_logs
-
- # Load original and generated logs
- orig_steps = load_jsonl_run(original_jsonl)
- python_steps = load_jsonl_run(python_log_path)
-
- assert len(orig_steps) == len(python_steps), (
- f"Different number of steps: Original={len(orig_steps)}, Generated={len(python_steps)}"
- )
-
- # Compare each step
- for i, (original_step, python_step) in enumerate(zip(orig_steps, python_steps)):
- context = f"step {i} in {original_jsonl.name} (Original vs Python logs)"
- assert_steps_equal(python_step, original_step, context)
diff --git a/tests/lua/test_protocol_errors.py b/tests/lua/test_protocol_errors.py
deleted file mode 100644
index 2bc7265..0000000
--- a/tests/lua/test_protocol_errors.py
+++ /dev/null
@@ -1,182 +0,0 @@
-"""Tests for BalatroBot TCP API protocol-level error handling."""
-
-import json
-import socket
-from typing import Generator
-
-import pytest
-
-from balatrobot.enums import ErrorCode
-
-from .conftest import assert_error_response, receive_api_message, send_api_message
-
-
-class TestProtocolErrors:
- """Tests for protocol-level error handling in the TCP API."""
-
- @pytest.fixture(autouse=True)
- def setup_and_teardown(
- self, tcp_client: socket.socket
- ) -> Generator[None, None, None]:
- """Set up and tear down each test method."""
- yield
- # Clean up by going to menu
- try:
- send_api_message(tcp_client, "go_to_menu", {})
- receive_api_message(tcp_client)
- except Exception:
- pass # Ignore cleanup errors
-
- def test_invalid_json_error(self, tcp_client: socket.socket) -> None:
- """Test E001: Invalid JSON message handling."""
- # Send malformed JSON
- tcp_client.send(b"{ invalid json }\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Invalid JSON",
- expected_error_code=ErrorCode.INVALID_JSON.value,
- )
-
- def test_missing_name_field_error(self, tcp_client: socket.socket) -> None:
- """Test E002: Missing name field in message."""
- # Send message without name field
- message = {"arguments": {}}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Message must contain a name",
- expected_error_code=ErrorCode.MISSING_NAME.value,
- )
-
- def test_missing_arguments_field_error(self, tcp_client: socket.socket) -> None:
- """Test E003: Missing arguments field in message."""
- # Send message without arguments field
- message = {"name": "get_game_state"}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Message must contain arguments",
- expected_error_code=ErrorCode.MISSING_ARGUMENTS.value,
- )
-
- def test_unknown_function_error(self, tcp_client: socket.socket) -> None:
- """Test E004: Unknown function name."""
- # Send message with non-existent function name
- message = {"name": "nonexistent_function", "arguments": {}}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Unknown function name",
- expected_error_code=ErrorCode.UNKNOWN_FUNCTION.value,
- expected_context_keys=["name"],
- )
- assert response["context"]["name"] == "nonexistent_function"
-
- def test_invalid_arguments_type_error(self, tcp_client: socket.socket) -> None:
- """Test E005: Arguments must be a table/dict."""
- # Send message with non-dict arguments
- message = {"name": "get_game_state", "arguments": "not_a_dict"}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Arguments must be a table",
- expected_error_code=ErrorCode.INVALID_ARGUMENTS.value,
- expected_context_keys=["received_type"],
- )
- assert response["context"]["received_type"] == "string"
-
- def test_invalid_arguments_number_error(self, tcp_client: socket.socket) -> None:
- """Test E005: Arguments as number instead of dict."""
- # Send message with number arguments
- message = {"name": "get_game_state", "arguments": 123}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Arguments must be a table",
- expected_error_code=ErrorCode.INVALID_ARGUMENTS.value,
- expected_context_keys=["received_type"],
- )
- assert response["context"]["received_type"] == "number"
-
- def test_invalid_arguments_null_error(self, tcp_client: socket.socket) -> None:
- """Test E003: Arguments as null (None) is treated as missing arguments."""
- # Send message with null arguments - Lua treats null as missing field
- message = {"name": "get_game_state", "arguments": None}
- tcp_client.send(json.dumps(message).encode() + b"\n")
-
- response = receive_api_message(tcp_client)
- assert_error_response(
- response,
- "Message must contain arguments",
- expected_error_code=ErrorCode.MISSING_ARGUMENTS.value,
- )
-
- def test_protocol_error_response_structure(self, tcp_client: socket.socket) -> None:
- """Test that all protocol errors have consistent response structure."""
- # Send invalid JSON to trigger protocol error
- tcp_client.send(b"{ malformed json }\n")
-
- response = receive_api_message(tcp_client)
-
- # Verify response has all required fields
- assert isinstance(response, dict)
- required_fields = {"error", "error_code", "state"}
- assert required_fields.issubset(response.keys())
-
- # Verify error code format
- assert response["error_code"].startswith("E")
- assert len(response["error_code"]) == 4 # Format: E001, E002, etc.
-
- # Verify state is an integer
- assert isinstance(response["state"], int)
-
- def test_multiple_protocol_errors_sequence(self, tcp_client: socket.socket) -> None:
- """Test that multiple protocol errors in sequence are handled correctly."""
- # Test sequence: invalid JSON -> missing name -> unknown function
-
- # 1. Invalid JSON
- tcp_client.send(b"{ invalid }\n")
- response1 = receive_api_message(tcp_client)
- assert_error_response(
- response1,
- "Invalid JSON",
- expected_error_code=ErrorCode.INVALID_JSON.value,
- )
-
- # 2. Missing name
- message2 = {"arguments": {}}
- tcp_client.send(json.dumps(message2).encode() + b"\n")
- response2 = receive_api_message(tcp_client)
- assert_error_response(
- response2,
- "Message must contain a name",
- expected_error_code=ErrorCode.MISSING_NAME.value,
- )
-
- # 3. Unknown function
- message3 = {"name": "fake_function", "arguments": {}}
- tcp_client.send(json.dumps(message3).encode() + b"\n")
- response3 = receive_api_message(tcp_client)
- assert_error_response(
- response3,
- "Unknown function name",
- expected_error_code=ErrorCode.UNKNOWN_FUNCTION.value,
- )
-
- # 4. Valid call should still work
- send_api_message(tcp_client, "get_game_state", {})
- valid_response = receive_api_message(tcp_client)
- assert "error" not in valid_response
- assert "state" in valid_response
diff --git a/uv.lock b/uv.lock
index c43a35a..8ae2fdd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,78 +1,79 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.13"
[[package]]
-name = "annotated-types"
-version = "0.7.0"
+name = "anyio"
+version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload_time = "2025-11-28T23:37:38.911Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload_time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "babel"
version = "2.17.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload_time = "2025-02-01T15:17:41.026Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload_time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "backrefs"
version = "5.9"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload_time = "2025-06-22T19:34:13.97Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" },
- { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" },
- { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" },
- { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" },
- { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload_time = "2025-06-22T19:34:05.252Z" },
+ { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload_time = "2025-06-22T19:34:06.743Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload_time = "2025-06-22T19:34:08.172Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload_time = "2025-06-22T19:34:09.68Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload_time = "2025-06-22T19:34:11.037Z" },
+ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload_time = "2025-06-22T19:34:12.405Z" },
]
[[package]]
name = "balatrobot"
version = "0.7.5"
-source = { editable = "." }
-dependencies = [
- { name = "pydantic" },
-]
+source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
- { name = "deepdiff" },
+ { name = "httpx" },
{ name = "mdformat-mkdocs" },
{ name = "mdformat-simple-breaks" },
{ name = "mkdocs-llmstxt" },
{ name = "mkdocs-material" },
- { name = "mkdocstrings", extra = ["python"] },
{ name = "pytest" },
{ name = "pytest-cov" },
+ { name = "pytest-rerunfailures" },
{ name = "pytest-xdist", extra = ["psutil"] },
{ name = "ruff" },
+ { name = "tqdm" },
]
[package.metadata]
-requires-dist = [{ name = "pydantic", specifier = ">=2.11.7" }]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.29.5" },
- { name = "deepdiff", specifier = ">=8.5.0" },
+ { name = "httpx", specifier = ">=0.28.1" },
{ name = "mdformat-mkdocs", specifier = ">=4.3.0" },
{ name = "mdformat-simple-breaks", specifier = ">=0.0.1" },
{ name = "mkdocs-llmstxt", specifier = ">=0.3.0" },
{ name = "mkdocs-material", specifier = ">=9.6.15" },
- { name = "mkdocstrings", extras = ["python"], specifier = ">=0.29.1" },
{ name = "pytest", specifier = ">=8.4.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
+ { name = "pytest-rerunfailures", specifier = ">=16.1" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" },
{ name = "ruff", specifier = ">=0.12.2" },
+ { name = "tqdm", specifier = ">=4.67.0" },
]
[[package]]
@@ -82,9 +83,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/76/4f/c0c12169a5373006ecd6bb8dfe1f8e4f2fd2d508be64b74b860a3f88baf3/basedpyright-1.29.5.tar.gz", hash = "sha256:468ad6305472a2b368a1f383c7914e9e4ff3173db719067e1575cf41ed7b5a36", size = 21962194, upload-time = "2025-06-30T10:39:58.973Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/4f/c0c12169a5373006ecd6bb8dfe1f8e4f2fd2d508be64b74b860a3f88baf3/basedpyright-1.29.5.tar.gz", hash = "sha256:468ad6305472a2b368a1f383c7914e9e4ff3173db719067e1575cf41ed7b5a36", size = 21962194, upload_time = "2025-06-30T10:39:58.973Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/a3/8293e5af46df07f76732aa33f3ceb8a7097c846d03257c74c0f5f4d69107/basedpyright-1.29.5-py3-none-any.whl", hash = "sha256:e7eee13bec8b3c20d718c6f3ef1e2d57fb04621408e742aa8c82a1bd82fe325b", size = 11476874, upload-time = "2025-06-30T10:39:54.662Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a3/8293e5af46df07f76732aa33f3ceb8a7097c846d03257c74c0f5f4d69107/basedpyright-1.29.5-py3-none-any.whl", hash = "sha256:e7eee13bec8b3c20d718c6f3ef1e2d57fb04621408e742aa8c82a1bd82fe325b", size = 11476874, upload_time = "2025-06-30T10:39:54.662Z" },
]
[[package]]
@@ -95,40 +96,40 @@ dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload_time = "2025-04-15T17:05:13.836Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
+ { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload_time = "2025-04-15T17:05:12.221Z" },
]
[[package]]
name = "certifi"
version = "2025.6.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload_time = "2025-06-15T02:45:51.329Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload_time = "2025-06-15T02:45:49.977Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload_time = "2025-05-02T08:34:42.01Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
- { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
- { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
- { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
- { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
- { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
- { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
- { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
- { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
- { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
- { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
- { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
- { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
- { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload_time = "2025-05-02T08:32:56.363Z" },
+ { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload_time = "2025-05-02T08:32:58.551Z" },
+ { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload_time = "2025-05-02T08:33:00.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload_time = "2025-05-02T08:33:02.081Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload_time = "2025-05-02T08:33:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload_time = "2025-05-02T08:33:06.418Z" },
+ { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload_time = "2025-05-02T08:33:08.183Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload_time = "2025-05-02T08:33:09.986Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload_time = "2025-05-02T08:33:11.814Z" },
+ { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload_time = "2025-05-02T08:33:13.707Z" },
+ { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload_time = "2025-05-02T08:33:15.458Z" },
+ { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload_time = "2025-05-02T08:33:17.06Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload_time = "2025-05-02T08:33:18.753Z" },
+ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload_time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
@@ -138,70 +139,58 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload_time = "2025-05-20T23:19:49.832Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
+ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload_time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.9.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload-time = "2025-07-03T10:54:15.101Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/9d/7a8edf7acbcaa5e5c489a646226bed9591ee1c5e6a84733c0140e9ce1ae1/coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038", size = 212367, upload-time = "2025-07-03T10:53:25.811Z" },
- { url = "https://files.pythonhosted.org/packages/e8/9e/5cd6f130150712301f7e40fb5865c1bc27b97689ec57297e568d972eec3c/coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d", size = 212632, upload-time = "2025-07-03T10:53:27.075Z" },
- { url = "https://files.pythonhosted.org/packages/a8/de/6287a2c2036f9fd991c61cefa8c64e57390e30c894ad3aa52fac4c1e14a8/coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3", size = 245793, upload-time = "2025-07-03T10:53:28.408Z" },
- { url = "https://files.pythonhosted.org/packages/06/cc/9b5a9961d8160e3cb0b558c71f8051fe08aa2dd4b502ee937225da564ed1/coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14", size = 243006, upload-time = "2025-07-03T10:53:29.754Z" },
- { url = "https://files.pythonhosted.org/packages/49/d9/4616b787d9f597d6443f5588619c1c9f659e1f5fc9eebf63699eb6d34b78/coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6", size = 244990, upload-time = "2025-07-03T10:53:31.098Z" },
- { url = "https://files.pythonhosted.org/packages/48/83/801cdc10f137b2d02b005a761661649ffa60eb173dcdaeb77f571e4dc192/coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b", size = 245157, upload-time = "2025-07-03T10:53:32.717Z" },
- { url = "https://files.pythonhosted.org/packages/c8/a4/41911ed7e9d3ceb0ffb019e7635468df7499f5cc3edca5f7dfc078e9c5ec/coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d", size = 243128, upload-time = "2025-07-03T10:53:34.009Z" },
- { url = "https://files.pythonhosted.org/packages/10/41/344543b71d31ac9cb00a664d5d0c9ef134a0fe87cb7d8430003b20fa0b7d/coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868", size = 244511, upload-time = "2025-07-03T10:53:35.434Z" },
- { url = "https://files.pythonhosted.org/packages/d5/81/3b68c77e4812105e2a060f6946ba9e6f898ddcdc0d2bfc8b4b152a9ae522/coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a", size = 214765, upload-time = "2025-07-03T10:53:36.787Z" },
- { url = "https://files.pythonhosted.org/packages/06/a2/7fac400f6a346bb1a4004eb2a76fbff0e242cd48926a2ce37a22a6a1d917/coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b", size = 215536, upload-time = "2025-07-03T10:53:38.188Z" },
- { url = "https://files.pythonhosted.org/packages/08/47/2c6c215452b4f90d87017e61ea0fd9e0486bb734cb515e3de56e2c32075f/coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694", size = 213943, upload-time = "2025-07-03T10:53:39.492Z" },
- { url = "https://files.pythonhosted.org/packages/a3/46/e211e942b22d6af5e0f323faa8a9bc7c447a1cf1923b64c47523f36ed488/coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5", size = 213088, upload-time = "2025-07-03T10:53:40.874Z" },
- { url = "https://files.pythonhosted.org/packages/d2/2f/762551f97e124442eccd907bf8b0de54348635b8866a73567eb4e6417acf/coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b", size = 213298, upload-time = "2025-07-03T10:53:42.218Z" },
- { url = "https://files.pythonhosted.org/packages/7a/b7/76d2d132b7baf7360ed69be0bcab968f151fa31abe6d067f0384439d9edb/coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3", size = 256541, upload-time = "2025-07-03T10:53:43.823Z" },
- { url = "https://files.pythonhosted.org/packages/a0/17/392b219837d7ad47d8e5974ce5f8dc3deb9f99a53b3bd4d123602f960c81/coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8", size = 252761, upload-time = "2025-07-03T10:53:45.19Z" },
- { url = "https://files.pythonhosted.org/packages/d5/77/4256d3577fe1b0daa8d3836a1ebe68eaa07dd2cbaf20cf5ab1115d6949d4/coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46", size = 254917, upload-time = "2025-07-03T10:53:46.931Z" },
- { url = "https://files.pythonhosted.org/packages/53/99/fc1a008eef1805e1ddb123cf17af864743354479ea5129a8f838c433cc2c/coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584", size = 256147, upload-time = "2025-07-03T10:53:48.289Z" },
- { url = "https://files.pythonhosted.org/packages/92/c0/f63bf667e18b7f88c2bdb3160870e277c4874ced87e21426128d70aa741f/coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e", size = 254261, upload-time = "2025-07-03T10:53:49.99Z" },
- { url = "https://files.pythonhosted.org/packages/8c/32/37dd1c42ce3016ff8ec9e4b607650d2e34845c0585d3518b2a93b4830c1a/coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac", size = 255099, upload-time = "2025-07-03T10:53:51.354Z" },
- { url = "https://files.pythonhosted.org/packages/da/2e/af6b86f7c95441ce82f035b3affe1cd147f727bbd92f563be35e2d585683/coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926", size = 215440, upload-time = "2025-07-03T10:53:52.808Z" },
- { url = "https://files.pythonhosted.org/packages/4d/bb/8a785d91b308867f6b2e36e41c569b367c00b70c17f54b13ac29bcd2d8c8/coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd", size = 216537, upload-time = "2025-07-03T10:53:54.273Z" },
- { url = "https://files.pythonhosted.org/packages/1d/a0/a6bffb5e0f41a47279fd45a8f3155bf193f77990ae1c30f9c224b61cacb0/coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb", size = 214398, upload-time = "2025-07-03T10:53:56.715Z" },
- { url = "https://files.pythonhosted.org/packages/3c/38/bbe2e63902847cf79036ecc75550d0698af31c91c7575352eb25190d0fb3/coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4", size = 204005, upload-time = "2025-07-03T10:54:13.491Z" },
-]
-
-[[package]]
-name = "deepdiff"
-version = "8.5.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "orderly-set" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/0a/0f/9cd2624f7dcd755cbf1fa21fb7234541f19a1be96a56f387ec9053ebe220/deepdiff-8.5.0.tar.gz", hash = "sha256:a4dd3529fa8d4cd5b9cbb6e3ea9c95997eaa919ba37dac3966c1b8f872dc1cd1", size = 538517, upload-time = "2025-05-09T18:44:10.035Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/3b/2e0797200c51531a6d8c97a8e4c9fa6fb56de7e6e2a15c1c067b6b10a0b0/deepdiff-8.5.0-py3-none-any.whl", hash = "sha256:d4599db637f36a1c285f5fdfc2cd8d38bde8d8be8636b65ab5e425b67c54df26", size = 85112, upload-time = "2025-05-09T18:44:07.784Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload_time = "2025-07-03T10:54:15.101Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/9d/7a8edf7acbcaa5e5c489a646226bed9591ee1c5e6a84733c0140e9ce1ae1/coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038", size = 212367, upload_time = "2025-07-03T10:53:25.811Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/9e/5cd6f130150712301f7e40fb5865c1bc27b97689ec57297e568d972eec3c/coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d", size = 212632, upload_time = "2025-07-03T10:53:27.075Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/de/6287a2c2036f9fd991c61cefa8c64e57390e30c894ad3aa52fac4c1e14a8/coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3", size = 245793, upload_time = "2025-07-03T10:53:28.408Z" },
+ { url = "https://files.pythonhosted.org/packages/06/cc/9b5a9961d8160e3cb0b558c71f8051fe08aa2dd4b502ee937225da564ed1/coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14", size = 243006, upload_time = "2025-07-03T10:53:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d9/4616b787d9f597d6443f5588619c1c9f659e1f5fc9eebf63699eb6d34b78/coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6", size = 244990, upload_time = "2025-07-03T10:53:31.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/83/801cdc10f137b2d02b005a761661649ffa60eb173dcdaeb77f571e4dc192/coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b", size = 245157, upload_time = "2025-07-03T10:53:32.717Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a4/41911ed7e9d3ceb0ffb019e7635468df7499f5cc3edca5f7dfc078e9c5ec/coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d", size = 243128, upload_time = "2025-07-03T10:53:34.009Z" },
+ { url = "https://files.pythonhosted.org/packages/10/41/344543b71d31ac9cb00a664d5d0c9ef134a0fe87cb7d8430003b20fa0b7d/coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868", size = 244511, upload_time = "2025-07-03T10:53:35.434Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/81/3b68c77e4812105e2a060f6946ba9e6f898ddcdc0d2bfc8b4b152a9ae522/coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a", size = 214765, upload_time = "2025-07-03T10:53:36.787Z" },
+ { url = "https://files.pythonhosted.org/packages/06/a2/7fac400f6a346bb1a4004eb2a76fbff0e242cd48926a2ce37a22a6a1d917/coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b", size = 215536, upload_time = "2025-07-03T10:53:38.188Z" },
+ { url = "https://files.pythonhosted.org/packages/08/47/2c6c215452b4f90d87017e61ea0fd9e0486bb734cb515e3de56e2c32075f/coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694", size = 213943, upload_time = "2025-07-03T10:53:39.492Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/46/e211e942b22d6af5e0f323faa8a9bc7c447a1cf1923b64c47523f36ed488/coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5", size = 213088, upload_time = "2025-07-03T10:53:40.874Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/2f/762551f97e124442eccd907bf8b0de54348635b8866a73567eb4e6417acf/coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b", size = 213298, upload_time = "2025-07-03T10:53:42.218Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b7/76d2d132b7baf7360ed69be0bcab968f151fa31abe6d067f0384439d9edb/coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3", size = 256541, upload_time = "2025-07-03T10:53:43.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/17/392b219837d7ad47d8e5974ce5f8dc3deb9f99a53b3bd4d123602f960c81/coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8", size = 252761, upload_time = "2025-07-03T10:53:45.19Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/77/4256d3577fe1b0daa8d3836a1ebe68eaa07dd2cbaf20cf5ab1115d6949d4/coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46", size = 254917, upload_time = "2025-07-03T10:53:46.931Z" },
+ { url = "https://files.pythonhosted.org/packages/53/99/fc1a008eef1805e1ddb123cf17af864743354479ea5129a8f838c433cc2c/coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584", size = 256147, upload_time = "2025-07-03T10:53:48.289Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c0/f63bf667e18b7f88c2bdb3160870e277c4874ced87e21426128d70aa741f/coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e", size = 254261, upload_time = "2025-07-03T10:53:49.99Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/32/37dd1c42ce3016ff8ec9e4b607650d2e34845c0585d3518b2a93b4830c1a/coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac", size = 255099, upload_time = "2025-07-03T10:53:51.354Z" },
+ { url = "https://files.pythonhosted.org/packages/da/2e/af6b86f7c95441ce82f035b3affe1cd147f727bbd92f563be35e2d585683/coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926", size = 215440, upload_time = "2025-07-03T10:53:52.808Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/bb/8a785d91b308867f6b2e36e41c569b367c00b70c17f54b13ac29bcd2d8c8/coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd", size = 216537, upload_time = "2025-07-03T10:53:54.273Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a0/a6bffb5e0f41a47279fd45a8f3155bf193f77990ae1c30f9c224b61cacb0/coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb", size = 214398, upload_time = "2025-07-03T10:53:56.715Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/38/bbe2e63902847cf79036ecc75550d0698af31c91c7575352eb25190d0fb3/coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4", size = 204005, upload_time = "2025-07-03T10:54:13.491Z" },
]
[[package]]
name = "execnet"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload_time = "2024-04-08T09:04:19.245Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" },
+ { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload_time = "2024-04-08T09:04:17.414Z" },
]
[[package]]
@@ -211,39 +200,64 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload_time = "2022-05-02T15:47:16.11Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload_time = "2022-05-02T15:47:14.552Z" },
]
[[package]]
-name = "griffe"
-version = "1.7.3"
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama" },
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
@@ -253,18 +267,18 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown"
version = "3.8.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload_time = "2025-06-19T17:12:44.483Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" },
+ { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload_time = "2025-06-19T17:12:42.994Z" },
]
[[package]]
@@ -274,9 +288,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
@@ -287,37 +301,37 @@ dependencies = [
{ name = "beautifulsoup4" },
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/78/c48fed23c7aebc2c16049062e72de1da3220c274de59d28c942acdc9ffb2/markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd", size = 17127, upload-time = "2025-03-05T11:54:40.574Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/78/c48fed23c7aebc2c16049062e72de1da3220c274de59d28c942acdc9ffb2/markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd", size = 17127, upload_time = "2025-03-05T11:54:40.574Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/64/11/b751af7ad41b254a802cf52f7bc1fca7cabe2388132f2ce60a1a6b9b9622/markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef", size = 13901, upload-time = "2025-03-05T11:54:39.454Z" },
+ { url = "https://files.pythonhosted.org/packages/64/11/b751af7ad41b254a802cf52f7bc1fca7cabe2388132f2ce60a1a6b9b9622/markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef", size = 13901, upload_time = "2025-03-05T11:54:39.454Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
- { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
- { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
- { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
- { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
- { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
- { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
- { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
- { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
- { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
- { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
- { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
- { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
- { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
- { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
- { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
- { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
- { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
- { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
- { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" },
+ { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" },
+ { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" },
+ { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
@@ -327,9 +341,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload_time = "2025-01-30T18:00:51.418Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload_time = "2025-01-30T18:00:48.708Z" },
]
[[package]]
@@ -342,9 +356,9 @@ dependencies = [
{ name = "mdformat-tables" },
{ name = "mdit-py-plugins" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e5/db/873bad63b36e390a33bc0cf7222442010997d3ccf29a1889f24d28fdeddd/mdformat_gfm-0.4.1.tar.gz", hash = "sha256:e189e728e50cfb15746abc6b3178ca0e2bebbb7a8d3d98fbc9e24bc1a4c65564", size = 7528, upload-time = "2024-12-13T09:21:27.212Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/db/873bad63b36e390a33bc0cf7222442010997d3ccf29a1889f24d28fdeddd/mdformat_gfm-0.4.1.tar.gz", hash = "sha256:e189e728e50cfb15746abc6b3178ca0e2bebbb7a8d3d98fbc9e24bc1a4c65564", size = 7528, upload_time = "2024-12-13T09:21:27.212Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/09/ba/3d4c680a2582593b8ba568ab60b119d93542fa39d757d65aae3c4f357e29/mdformat_gfm-0.4.1-py3-none-any.whl", hash = "sha256:63c92cfa5102f55779d4e04b16a79a6a5171e658c6c479175c0955fb4ca78dde", size = 8750, upload-time = "2024-12-13T09:21:25.158Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ba/3d4c680a2582593b8ba568ab60b119d93542fa39d757d65aae3c4f357e29/mdformat_gfm-0.4.1-py3-none-any.whl", hash = "sha256:63c92cfa5102f55779d4e04b16a79a6a5171e658c6c479175c0955fb4ca78dde", size = 8750, upload_time = "2024-12-13T09:21:25.158Z" },
]
[[package]]
@@ -357,9 +371,9 @@ dependencies = [
{ name = "mdit-py-plugins" },
{ name = "more-itertools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1b/85/735e11fe6a410c5005b4fb06bc2c75df56cbbcea84ad6dc101e5edae67f9/mdformat_mkdocs-4.3.0.tar.gz", hash = "sha256:d4d9b381d13900a373c1673bd72175a28d712e5ec3d9688d09e66ab4174c493c", size = 27996, upload-time = "2025-05-31T02:15:21.208Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/85/735e11fe6a410c5005b4fb06bc2c75df56cbbcea84ad6dc101e5edae67f9/mdformat_mkdocs-4.3.0.tar.gz", hash = "sha256:d4d9b381d13900a373c1673bd72175a28d712e5ec3d9688d09e66ab4174c493c", size = 27996, upload_time = "2025-05-31T02:15:21.208Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/25/cd4edbe9e5f96048999afbd42d0c030c66c2d45f2119002e7de208e6d43a/mdformat_mkdocs-4.3.0-py3-none-any.whl", hash = "sha256:13e9512b9461c9af982c3b9e1640791d38b0835549e5f8a7ee641926feae4d58", size = 31422, upload-time = "2025-05-31T02:15:20.182Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/25/cd4edbe9e5f96048999afbd42d0c030c66c2d45f2119002e7de208e6d43a/mdformat_mkdocs-4.3.0-py3-none-any.whl", hash = "sha256:13e9512b9461c9af982c3b9e1640791d38b0835549e5f8a7ee641926feae4d58", size = 31422, upload_time = "2025-05-31T02:15:20.182Z" },
]
[[package]]
@@ -369,9 +383,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdformat" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/62/af/5b88367b69084c8bc60cfc4103eb62482655f8dbbb6dc81431aa27455b22/mdformat_simple_breaks-0.0.1.tar.gz", hash = "sha256:36dbd4981e177526c08cfd9b36272dd2caf230d7a2c2834c852e57a1649f676a", size = 6501, upload-time = "2022-12-29T16:43:53.585Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/62/af/5b88367b69084c8bc60cfc4103eb62482655f8dbbb6dc81431aa27455b22/mdformat_simple_breaks-0.0.1.tar.gz", hash = "sha256:36dbd4981e177526c08cfd9b36272dd2caf230d7a2c2834c852e57a1649f676a", size = 6501, upload_time = "2022-12-29T16:43:53.585Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/1d/8e992c96d7ac86f85e1b4094d3887658376443b56679b834de8b23f4f849/mdformat_simple_breaks-0.0.1-py3-none-any.whl", hash = "sha256:3dde7209d509620fdd2bf11e780ae32d5f61a80ea145a598252e2703f8571407", size = 4012, upload-time = "2022-12-29T16:43:52.162Z" },
+ { url = "https://files.pythonhosted.org/packages/20/1d/8e992c96d7ac86f85e1b4094d3887658376443b56679b834de8b23f4f849/mdformat_simple_breaks-0.0.1-py3-none-any.whl", hash = "sha256:3dde7209d509620fdd2bf11e780ae32d5f61a80ea145a598252e2703f8571407", size = 4012, upload_time = "2022-12-29T16:43:52.162Z" },
]
[[package]]
@@ -382,9 +396,9 @@ dependencies = [
{ name = "mdformat" },
{ name = "wcwidth" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload-time = "2024-08-23T23:41:33.413Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload_time = "2024-08-23T23:41:33.413Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload-time = "2024-08-23T23:41:31.863Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload_time = "2024-08-23T23:41:31.863Z" },
]
[[package]]
@@ -394,27 +408,27 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload_time = "2024-09-09T20:27:49.564Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload_time = "2024-09-09T20:27:48.397Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mergedeep"
version = "1.3.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload_time = "2021-02-05T18:55:30.623Z" }
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" },
+ { 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]]
@@ -436,23 +450,9 @@ dependencies = [
{ name = "pyyaml-env-tag" },
{ name = "watchdog" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload_time = "2024-08-30T12:24:06.899Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
-]
-
-[[package]]
-name = "mkdocs-autorefs"
-version = "1.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markdown" },
- { name = "markupsafe" },
- { name = "mkdocs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/47/0c/c9826f35b99c67fa3a7cddfa094c1a6c43fafde558c309c6e4403e5b37dc/mkdocs_autorefs-1.4.2.tar.gz", hash = "sha256:e2ebe1abd2b67d597ed19378c0fff84d73d1dbce411fce7a7cc6f161888b6749", size = 54961, upload-time = "2025-05-20T13:09:09.886Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/87/dc/fc063b78f4b769d1956319351704e23ebeba1e9e1d6a41b4b602325fd7e4/mkdocs_autorefs-1.4.2-py3-none-any.whl", hash = "sha256:83d6d777b66ec3c372a1aad4ae0cf77c243ba5bcda5bf0c6b8a2c5e7a3d89f13", size = 24969, upload-time = "2025-05-20T13:09:08.237Z" },
+ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload_time = "2024-08-30T12:24:05.054Z" },
]
[[package]]
@@ -464,9 +464,9 @@ dependencies = [
{ name = "platformdirs" },
{ name = "pyyaml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload_time = "2023-11-20T17:51:09.981Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload_time = "2023-11-20T17:51:08.587Z" },
]
[[package]]
@@ -479,9 +479,9 @@ dependencies = [
{ name = "mdformat" },
{ name = "mdformat-tables" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/34/1d/825a2d5ed8a04c6ede9ffb2e73f53caf2097e587cab30bb263ec33962701/mkdocs_llmstxt-0.3.0.tar.gz", hash = "sha256:97b4558ec658ee2927c1ff9eeb5c9a06ca9d9f0fc0697b530794e6acda45b970", size = 31361, upload-time = "2025-07-14T20:14:48.81Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/1d/825a2d5ed8a04c6ede9ffb2e73f53caf2097e587cab30bb263ec33962701/mkdocs_llmstxt-0.3.0.tar.gz", hash = "sha256:97b4558ec658ee2927c1ff9eeb5c9a06ca9d9f0fc0697b530794e6acda45b970", size = 31361, upload_time = "2025-07-14T20:14:48.81Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/7d/77a53edbf5456cb4b244f81d513a49235e4fdcbda41b6a9dbd0e84d35e9b/mkdocs_llmstxt-0.3.0-py3-none-any.whl", hash = "sha256:87f0df4a8051f3dfe15574cff1e1464f9fd09318e3dbbb35d08a7457ee3a5ce8", size = 11290, upload-time = "2025-07-14T20:14:47.311Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7d/77a53edbf5456cb4b244f81d513a49235e4fdcbda41b6a9dbd0e84d35e9b/mkdocs_llmstxt-0.3.0-py3-none-any.whl", hash = "sha256:87f0df4a8051f3dfe15574cff1e1464f9fd09318e3dbbb35d08a7457ee3a5ce8", size = 11290, upload_time = "2025-07-14T20:14:47.311Z" },
]
[[package]]
@@ -501,200 +501,112 @@ dependencies = [
{ name = "pymdown-extensions" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/95/c1/f804ba2db2ddc2183e900befe7dad64339a34fa935034e1ab405289d0a97/mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5", size = 3951836, upload-time = "2025-07-01T10:14:15.671Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/c1/f804ba2db2ddc2183e900befe7dad64339a34fa935034e1ab405289d0a97/mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5", size = 3951836, upload_time = "2025-07-01T10:14:15.671Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/30/dda19f0495a9096b64b6b3c07c4bfcff1c76ee0fc521086d53593f18b4c0/mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a", size = 8716840, upload-time = "2025-07-01T10:14:13.18Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/30/dda19f0495a9096b64b6b3c07c4bfcff1c76ee0fc521086d53593f18b4c0/mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a", size = 8716840, upload_time = "2025-07-01T10:14:13.18Z" },
]
[[package]]
name = "mkdocs-material-extensions"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
-]
-
-[[package]]
-name = "mkdocstrings"
-version = "0.29.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jinja2" },
- { name = "markdown" },
- { name = "markupsafe" },
- { name = "mkdocs" },
- { name = "mkdocs-autorefs" },
- { name = "pymdown-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686, upload-time = "2025-03-31T08:33:11.997Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075, upload-time = "2025-03-31T08:33:09.661Z" },
-]
-
-[package.optional-dependencies]
-python = [
- { name = "mkdocstrings-python" },
-]
-
-[[package]]
-name = "mkdocstrings-python"
-version = "1.16.12"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "griffe" },
- { name = "mkdocs-autorefs" },
- { name = "mkdocstrings" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/bf/ed/b886f8c714fd7cccc39b79646b627dbea84cd95c46be43459ef46852caf0/mkdocstrings_python-1.16.12.tar.gz", hash = "sha256:9b9eaa066e0024342d433e332a41095c4e429937024945fea511afe58f63175d", size = 206065, upload-time = "2025-06-03T12:52:49.276Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload_time = "2023-11-22T19:09:45.208Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/dd/a24ee3de56954bfafb6ede7cd63c2413bb842cc48eb45e41c43a05a33074/mkdocstrings_python-1.16.12-py3-none-any.whl", hash = "sha256:22ded3a63b3d823d57457a70ff9860d5a4de9e8b1e482876fc9baabaf6f5f374", size = 124287, upload-time = "2025-06-03T12:52:47.819Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload_time = "2023-11-22T19:09:43.465Z" },
]
[[package]]
name = "more-itertools"
version = "10.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload_time = "2025-04-22T14:17:41.838Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload_time = "2025-04-22T14:17:40.49Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "22.17.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/86/8962d1d24ff480f4dd31871f42c8e0d8e2c851cd558a07ee689261d310ab/nodejs_wheel_binaries-22.17.0.tar.gz", hash = "sha256:529142012fb8fd20817ef70e2ef456274df4f49933292e312c8bbc7285af6408", size = 8068, upload-time = "2025-06-29T20:24:25.002Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/53/b942c6da4ff6f87a315033f6ff6fed8fd3c22047d7ff5802badaa5dfc2c2/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6545a6f6d2f736d9c9e2eaad7e599b6b5b2d8fd4cbd2a1df0807cbcf51b9d39b", size = 51003554, upload-time = "2025-06-29T20:23:47.042Z" },
- { url = "https://files.pythonhosted.org/packages/e2/b7/7184a9ad2364912da22f2fe021dc4a3301721131ef7759aeb4a1f19db0b4/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4bea5b994dd87c20f8260031ea69a97c3d282e2d4472cc8908636a313a830d00", size = 51936848, upload-time = "2025-06-29T20:23:52.064Z" },
- { url = "https://files.pythonhosted.org/packages/e9/7a/0ea425147b8110b8fd65a6c21cfd3bd130cdec7766604361429ef870d799/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885508615274a22499dd5314759c1cf96ba72de03e6485d73b3e5475e7f12662", size = 57925230, upload-time = "2025-06-29T20:23:56.81Z" },
- { url = "https://files.pythonhosted.org/packages/23/5f/10a3f2ac08a839d065d9ccfd6d9df66bc46e100eaf87a8a5cf149eb3fb8e/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f38ce034a602bcab534d55cbe0390521e73e5dcffdd1c4b34354b932172af2", size = 58457829, upload-time = "2025-06-29T20:24:01.945Z" },
- { url = "https://files.pythonhosted.org/packages/ed/a4/d2ca331e16eef0974eb53702df603c54f77b2a7e2007523ecdbf6cf61162/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5eed087855b644c87001fe04036213193963ccd65e7f89949e9dbe28e7743d9b", size = 59778054, upload-time = "2025-06-29T20:24:07.14Z" },
- { url = "https://files.pythonhosted.org/packages/be/2b/04e0e7f7305fe2ba30fd4610bfb432516e0f65379fe6c2902f4b7b1ad436/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715f413c81500f0770ea8936ef1fc2529b900da8054cbf6da67cec3ee308dc76", size = 60830079, upload-time = "2025-06-29T20:24:12.21Z" },
- { url = "https://files.pythonhosted.org/packages/ce/67/12070b24b88040c2d694883f3dcb067052f748798f4c63f7c865769a5747/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_amd64.whl", hash = "sha256:51165630493c8dd4acfe1cae1684b76940c9b03f7f355597d55e2d056a572ddd", size = 40117877, upload-time = "2025-06-29T20:24:17.51Z" },
- { url = "https://files.pythonhosted.org/packages/2e/ec/53ac46af423527c23e40c7343189f2bce08a8337efedef4d8a33392cee23/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_arm64.whl", hash = "sha256:fae56d172227671fccb04461d3cd2b26a945c6c7c7fc29edb8618876a39d8b4a", size = 38865278, upload-time = "2025-06-29T20:24:21.065Z" },
-]
-
-[[package]]
-name = "orderly-set"
-version = "5.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/86/8962d1d24ff480f4dd31871f42c8e0d8e2c851cd558a07ee689261d310ab/nodejs_wheel_binaries-22.17.0.tar.gz", hash = "sha256:529142012fb8fd20817ef70e2ef456274df4f49933292e312c8bbc7285af6408", size = 8068, upload_time = "2025-06-29T20:24:25.002Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/53/b942c6da4ff6f87a315033f6ff6fed8fd3c22047d7ff5802badaa5dfc2c2/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6545a6f6d2f736d9c9e2eaad7e599b6b5b2d8fd4cbd2a1df0807cbcf51b9d39b", size = 51003554, upload_time = "2025-06-29T20:23:47.042Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b7/7184a9ad2364912da22f2fe021dc4a3301721131ef7759aeb4a1f19db0b4/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4bea5b994dd87c20f8260031ea69a97c3d282e2d4472cc8908636a313a830d00", size = 51936848, upload_time = "2025-06-29T20:23:52.064Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/7a/0ea425147b8110b8fd65a6c21cfd3bd130cdec7766604361429ef870d799/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885508615274a22499dd5314759c1cf96ba72de03e6485d73b3e5475e7f12662", size = 57925230, upload_time = "2025-06-29T20:23:56.81Z" },
+ { url = "https://files.pythonhosted.org/packages/23/5f/10a3f2ac08a839d065d9ccfd6d9df66bc46e100eaf87a8a5cf149eb3fb8e/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f38ce034a602bcab534d55cbe0390521e73e5dcffdd1c4b34354b932172af2", size = 58457829, upload_time = "2025-06-29T20:24:01.945Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a4/d2ca331e16eef0974eb53702df603c54f77b2a7e2007523ecdbf6cf61162/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5eed087855b644c87001fe04036213193963ccd65e7f89949e9dbe28e7743d9b", size = 59778054, upload_time = "2025-06-29T20:24:07.14Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2b/04e0e7f7305fe2ba30fd4610bfb432516e0f65379fe6c2902f4b7b1ad436/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715f413c81500f0770ea8936ef1fc2529b900da8054cbf6da67cec3ee308dc76", size = 60830079, upload_time = "2025-06-29T20:24:12.21Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/67/12070b24b88040c2d694883f3dcb067052f748798f4c63f7c865769a5747/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_amd64.whl", hash = "sha256:51165630493c8dd4acfe1cae1684b76940c9b03f7f355597d55e2d056a572ddd", size = 40117877, upload_time = "2025-06-29T20:24:17.51Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/ec/53ac46af423527c23e40c7343189f2bce08a8337efedef4d8a33392cee23/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_arm64.whl", hash = "sha256:fae56d172227671fccb04461d3cd2b26a945c6c7c7fc29edb8618876a39d8b4a", size = 38865278, upload_time = "2025-06-29T20:24:21.065Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "paginate"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload_time = "2024-08-25T14:17:24.139Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
+ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload_time = "2024-08-25T14:17:22.55Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload_time = "2023-12-10T22:30:45Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload_time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "platformdirs"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload_time = "2025-05-07T22:47:42.121Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload_time = "2025-05-07T22:47:40.376Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload_time = "2025-02-13T21:54:07.946Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
- { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
- { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
- { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
- { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
- { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
- { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
-]
-
-[[package]]
-name = "pydantic"
-version = "2.11.7"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.33.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
- { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
- { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
- { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
- { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
- { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
- { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
- { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
- { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
- { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
- { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
- { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
- { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
- { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
- { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
- { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload_time = "2025-02-13T21:54:12.36Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload_time = "2025-02-13T21:54:16.07Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload_time = "2025-02-13T21:54:18.662Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload_time = "2025-02-13T21:54:21.811Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload_time = "2025-02-13T21:54:24.68Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload_time = "2025-02-13T21:54:34.31Z" },
+ { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload_time = "2025-02-13T21:54:37.486Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
@@ -705,9 +617,9 @@ dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload-time = "2025-06-21T17:56:36.974Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/0a/c06b542ac108bfc73200677309cd9188a3a01b127a63f20cadc18d873d88/pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de", size = 853197, upload_time = "2025-06-21T17:56:36.974Z" }
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" },
+ { 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]]
@@ -721,9 +633,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload_time = "2025-06-18T05:48:06.109Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
+ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload_time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
@@ -735,9 +647,22 @@ dependencies = [
{ name = "pluggy" },
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload_time = "2025-06-12T10:47:47.684Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload_time = "2025-06-12T10:47:45.932Z" },
+]
+
+[[package]]
+name = "pytest-rerunfailures"
+version = "16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload_time = "2025-10-10T07:06:01.238Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
+ { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload_time = "2025-10-10T07:06:00.019Z" },
]
[[package]]
@@ -748,9 +673,9 @@ dependencies = [
{ name = "execnet" },
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload_time = "2025-07-01T13:30:59.346Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload_time = "2025-07-01T13:30:56.632Z" },
]
[package.optional-dependencies]
@@ -765,26 +690,26 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload_time = "2024-03-01T18:36:20.211Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload_time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
- { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
- { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
- { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
- { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
- { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
- { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" },
+ { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" },
+ { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
@@ -794,9 +719,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload_time = "2025-05-13T15:24:01.64Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
+ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload_time = "2025-05-13T15:23:59.629Z" },
]
[[package]]
@@ -809,110 +734,110 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload_time = "2025-06-09T16:43:07.34Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload_time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
name = "ruff"
version = "0.12.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload_time = "2025-07-03T16:40:19.566Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" },
- { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" },
- { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" },
- { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" },
- { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" },
- { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" },
- { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" },
- { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" },
- { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" },
- { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" },
- { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" },
- { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" },
- { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" },
- { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" },
- { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" },
- { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" },
- { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" },
+ { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload_time = "2025-07-03T16:39:38.847Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload_time = "2025-07-03T16:39:42.294Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload_time = "2025-07-03T16:39:44.75Z" },
+ { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload_time = "2025-07-03T16:39:47.652Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload_time = "2025-07-03T16:39:49.641Z" },
+ { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload_time = "2025-07-03T16:39:52.069Z" },
+ { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload_time = "2025-07-03T16:39:54.551Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload_time = "2025-07-03T16:39:57.55Z" },
+ { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload_time = "2025-07-03T16:39:59.78Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload_time = "2025-07-03T16:40:01.934Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload_time = "2025-07-03T16:40:04.363Z" },
+ { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload_time = "2025-07-03T16:40:06.514Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload_time = "2025-07-03T16:40:08.708Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload_time = "2025-07-03T16:40:10.836Z" },
+ { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload_time = "2025-07-03T16:40:13.203Z" },
+ { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload_time = "2025-07-03T16:40:15.478Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload_time = "2025-07-03T16:40:17.677Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload_time = "2024-12-04T17:35:28.174Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload_time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "soupsieve"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload_time = "2025-04-20T18:50:08.518Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload_time = "2025-04-20T18:50:07.196Z" },
]
[[package]]
-name = "typing-extensions"
-version = "4.14.1"
+name = "tqdm"
+version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload_time = "2024-11-24T20:12:22.481Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload_time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
-name = "typing-inspection"
-version = "0.4.1"
+name = "typing-extensions"
+version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload_time = "2025-07-04T13:28:34.16Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload_time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload_time = "2025-06-18T14:07:41.644Z" }
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" },
+ { 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 = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload_time = "2024-11-01T14:07:13.037Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
- { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
- { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
- { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
- { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
- { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
- { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
- { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
- { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
- { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
- { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
- { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload_time = "2024-11-01T14:06:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload_time = "2024-11-01T14:06:45.084Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload_time = "2024-11-01T14:06:47.324Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload_time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload_time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload_time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload_time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload_time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload_time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload_time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload_time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload_time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload_time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.13"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload_time = "2024-01-06T02:10:57.829Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload_time = "2024-01-06T02:10:55.763Z" },
]